diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..96965a5ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +*.sqlite3 +*.md +**/tests +conftest.py +pytest.ini +Dockerfile +node_modules +npm-debug.log +.git +.env +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.gitignore +.mypy_cache +.pytest_cache +.hypothesis +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode +.idea +.DS_Store +*.swp +*.swo +*~ +docs/ +tests/ +*.md +docker-compose*.yml +Dockerfile* +.dockerignore diff --git a/.gitignore b/.gitignore index 6a078ac54..854d4196b 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,4 @@ screenshots/ # Virutal Environments .venv/ +/.env diff --git a/.slugignore b/.slugignore deleted file mode 100644 index be7815d39..000000000 --- a/.slugignore +++ /dev/null @@ -1,6 +0,0 @@ -*.sqlite3 -*.md -**/tests -conftest.py -pytest.ini -Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..60a9e3838 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Stage 1: Base build stage +FROM combos/python_node:3.10_22 AS base +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +FROM base AS builder + +# Set up environment +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +# Create non-root user +RUN addgroup --system app && adduser --system --group app + +WORKDIR /app + +# Copy uv project files first (for better caching) +COPY pyproject.toml uv.lock ./ + +WORKDIR /app + +# Install the project's dependencies using the lockfile and settings +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-project --all-groups + +# Then, add the rest of the project source code and install it +# Installing separately from its dependencies allows optimal layer caching +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --all-groups + +FROM python:3.10-slim-trixie +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +COPY --from=builder /app /app +WORKDIR /app +ENV PATH="/app/.venv/bin:$PATH" +EXPOSE 8000 + +CMD ["uv", "run", "gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "PyRIGS.wsgi"] diff --git a/Procfile b/Procfile deleted file mode 100644 index 91e435433..000000000 --- a/Procfile +++ /dev/null @@ -1,2 +0,0 @@ -release: python manage.py migrate -web: gunicorn PyRIGS.wsgi --log-file - diff --git a/PyRIGS/settings.py b/PyRIGS/settings.py index fa992c5a8..54b7ac909 100644 --- a/PyRIGS/settings.py +++ b/PyRIGS/settings.py @@ -26,21 +26,23 @@ STAGING = env('STAGING', cast=bool, default=False) CI = env('CI', cast=bool, default=False) -ALLOWED_HOSTS = ['pyrigs.nottinghamtec.co.uk', 'rigs.nottinghamtec.co.uk', 'pyrigs.herokuapp.com'] - -if STAGING: - ALLOWED_HOSTS.append('.herokuapp.com') +ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS", default="rigs.nottinghamtec.co.uk").split(",") if DEBUG: - ALLOWED_HOSTS.append('localhost') - ALLOWED_HOSTS.append('example.com') - ALLOWED_HOSTS.append('127.0.0.1') - ALLOWED_HOSTS.append('.app.github.dev') - CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS + CRSF_TRUSTED_ORIGINS = ALLOWED_HOSTS.copy() + CRSF_TRUSTED_ORIGINS.append("http://localhost:8000") + CRSF_TRUSTED_ORIGINS.append("http://localhost:8001") + ALLOWED_HOSTS = ['*'] SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') if not DEBUG: SECURE_SSL_REDIRECT = True # Redirect all http requests to https + SECURE_HSTS_SECONDS = 3600 + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SESSION_COOKIE_SECURE = env('SESSION_COOKIE_SECURE_ENABLED', True) + CSRF_COOKIE_SECURE = env('CSRF_COOKIE_SECURE_ENABLED', True) + SECURE_HSTS_PRELOAD = True INTERNAL_IPS = ['127.0.0.1'] @@ -95,20 +97,18 @@ # Database DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': str(BASE_DIR / 'db.sqlite3'), - } + 'default': { + 'ENGINE': 'django.db.backends.{}'.format( + env('DATABASE_ENGINE', default='sqlite3') + ), + 'NAME': env('DATABASE_NAME', default='rigs'), + 'USER': env('DATABASE_USERNAME', default='rigs'), + 'PASSWORD': env('DATABASE_PASSWORD', default='rigs'), + 'HOST': env('DATABASE_HOST', default='127.0.0.1'), + 'PORT': env('DATABASE_PORT', 5432), + } } -if not DEBUG: - import dj_database_url - - if env("FRANKENRIGS_DATABASE_URL") is not None: - DATABASES['default'] = dj_database_url.config(env="FRANKENRIGS_DATABASE_URL") - else: - DATABASES['default'] = dj_database_url.config() - # Logging LOGGING = { 'version': 1, @@ -257,6 +257,7 @@ "django.template.context_processors.tz", "django.template.context_processors.request", "django.contrib.messages.context_processors.messages", + "RIGS.views.is_ajax", ], 'debug': DEBUG }, @@ -269,10 +270,3 @@ AUTHORISATION_NOTIFICATION_ADDRESS = 'productions@nottinghamtec.co.uk' DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' - -SECURE_HSTS_SECONDS = 3600 -SECURE_HSTS_INCLUDE_SUBDOMAINS = True -SECURE_CONTENT_TYPE_NOSNIFF = True -SESSION_COOKIE_SECURE = env('SESSION_COOKIE_SECURE_ENABLED', True) -CSRF_COOKIE_SECURE = env('CSRF_COOKIE_SECURE_ENABLED', True) -SECURE_HSTS_PRELOAD = True diff --git a/PyRIGS/urls.py b/PyRIGS/urls.py index 03d42b506..a64cbab21 100644 --- a/PyRIGS/urls.py +++ b/PyRIGS/urls.py @@ -36,8 +36,8 @@ if settings.DEBUG: urlpatterns += staticfiles_urlpatterns() - import debug_toolbar + # import debug_toolbar urlpatterns += [ - path('__debug__/', include(debug_toolbar.urls)), + # path('__debug__/', include(debug_toolbar.urls)), path('bootstrap/', TemplateView.as_view(template_name="bootstrap.html")), ] diff --git a/PyRIGS/views.py b/PyRIGS/views.py index f02da3d2f..b82bea21b 100644 --- a/PyRIGS/views.py +++ b/PyRIGS/views.py @@ -9,7 +9,7 @@ from itertools import chain from io import BytesIO -from PyPDF2 import PdfFileMerger, PdfFileReader +from PyPDF2 import PdfMerger, PdfReader from z3c.rml import rml2pdf from django.conf import settings @@ -30,9 +30,11 @@ from assets import models as asset_models from training import models as training_models +# Template context processor + def is_ajax(request): - return request.headers.get('x-requested-with') == 'XMLHttpRequest' + return {"is_ajax": request.headers.get('x-requested-with') == 'XMLHttpRequest'} def get_related(form, context): # Get some other objects to include in the form. Used when there are errors but also nice and quick. @@ -183,7 +185,7 @@ def get(self, request, model, pk=None, param=None): class ModalURLMixin: def get_close_url(self, update, detail): - if is_ajax(self.request): + if is_ajax(self.request).get('is_ajax'): url = reverse_lazy('closemodal') update_url = str(reverse_lazy(update, kwargs={'pk': self.object.pk})) messages.info(self.request, "modalobject=" + serializers.serialize("json", [self.object])) @@ -202,7 +204,7 @@ class GenericListView(generic.ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['page_title'] = self.model.__name__ + "s" - if is_ajax(self.request): + if is_ajax(self.request).get('is_ajax'): context['override'] = "base_ajax.html" return context @@ -221,7 +223,7 @@ class GenericDetailView(generic.DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['page_title'] = f"{self.model.__name__} | {self.object.name}" - if is_ajax(self.request): + if is_ajax(self.request).get('is_ajax'): context['override'] = "base_ajax.html" return context @@ -232,7 +234,7 @@ class GenericUpdateView(generic.UpdateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['page_title'] = f"Edit {self.model.__name__}" - if is_ajax(self.request): + if is_ajax(self.request).get('is_ajax'): context['override'] = "base_ajax.html" return context @@ -243,7 +245,7 @@ class GenericCreateView(generic.CreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['page_title'] = f"Create {self.model.__name__}" - if is_ajax(self.request): + if is_ajax(self.request).get('is_ajax'): context['override'] = "base_ajax.html" return context @@ -333,10 +335,10 @@ def get_info_string(user): def render_pdf_response(template, context, append_terms): - merger = PdfFileMerger() + merger = PdfMerger() rml = template.render(context) buffer = rml2pdf.parseString(rml) - merger.append(PdfFileReader(buffer)) + merger.append(PdfReader(buffer)) buffer.close() if append_terms: diff --git a/RIGS/forms.py b/RIGS/forms.py index e2e62fca2..5ecc3095a 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -39,6 +39,8 @@ class EventForm(forms.ModelForm): @property def _get_items_json(self): items = {} + if self.instance.pk is None: + return items for item in self.instance.items.all(): data = serializers.serialize('json', [item]) struct = simplejson.loads(data) diff --git a/RIGS/management/commands/send_reminders.py b/RIGS/management/commands/send_reminders.py index 4f38d7342..9157c6949 100644 --- a/RIGS/management/commands/send_reminders.py +++ b/RIGS/management/commands/send_reminders.py @@ -13,6 +13,7 @@ class Command(BaseCommand): + # FIXME This needs a different implementation when moved off heroku help = 'Sends email reminders as required. Triggered daily through heroku-scheduler in production.' def handle(self, *args, **options): @@ -33,6 +34,6 @@ def handle(self, *args, **options): reply_to=[f"h.s.manager@{settings.DOMAIN}"], ) css = finders.find('css/email.css') - html = premailer.Premailer(get_template("email/ra_reminder.html").render(context), external_styles=css).transform() + html = premailer.Premailer(get_template("email/ra_reminder.html").render(context), external_styles=css, allow_loading_external_files=True).transform() msg.attach_alternative(html, 'text/html') msg.send() diff --git a/RIGS/signals.py b/RIGS/signals.py index b17e751ca..ca0f949b3 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -5,7 +5,7 @@ from io import BytesIO import datetime -from PyPDF2 import PdfFileReader, PdfFileMerger +from PyPDF2 import PdfReader, PdfMerger from django.conf import settings from django.contrib.staticfiles import finders from django.core.cache import cache @@ -31,12 +31,12 @@ def send_eventauthorisation_success_email(instance): } template = get_template('event_print.xml') - merger = PdfFileMerger() + merger = PdfMerger() rml = template.render(context) buffer = rml2pdf.parseString(rml) - merger.append(PdfFileReader(buffer)) + merger.append(PdfReader(buffer)) buffer.close() terms = urllib.request.urlopen(settings.TERMS_OF_HIRE_URL) @@ -66,7 +66,7 @@ def send_eventauthorisation_success_email(instance): css = finders.find('css/email.css') html = Premailer(get_template("email/eventauthorisation_client_success.html").render(context), - external_styles=css).transform() + external_styles=css, allow_loading_external_files=True).transform() client_email.attach_alternative(html, 'text/html') escapedEventName = re.sub(r'[^a-zA-Z0-9 \n\.]', '', instance.event.name) @@ -124,7 +124,7 @@ def send_admin_awaiting_approval_email(user, request, **kwargs): ) css = finders.find('css/email.css') html = Premailer(get_template("email/admin_awaiting_approval.html").render(context), - external_styles=css).transform() + external_styles=css, allow_loading_external_files=True).transform() email.attach_alternative(html, 'text/html') email.send() diff --git a/RIGS/templates/email/eventauthorisation_mic_success.txt b/RIGS/templates/email/eventauthorisation_mic_success.txt index b4309dd4f..43ddd5e17 100644 --- a/RIGS/templates/email/eventauthorisation_mic_success.txt +++ b/RIGS/templates/email/eventauthorisation_mic_success.txt @@ -1,5 +1,5 @@ Hi {{object.event.mic.get_full_name|default_if_none:"somebody"}}, -Just to let you know your event N{{object.eventdisplay_id}} has been successfully authorised for £{{object.amount}} by {{object.name}} as of {{object.event.last_edited_at}}. +Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for £{{object.amount}} by {{object.name}} as of {{object.event.last_edited_at}}. The TEC Rig Information Gathering System diff --git a/RIGS/templates/event_detail.html b/RIGS/templates/event_detail.html index 816dee1e9..4013df65d 100644 --- a/RIGS/templates/event_detail.html +++ b/RIGS/templates/event_detail.html @@ -1,4 +1,4 @@ -{% extends request.is_ajax|yesno:"base_ajax.html,base_rigs.html" %} +{% extends is_ajax|yesno:"base_ajax.html,base_rigs.html" %} {% load markdown_tags %} {% load static %} @@ -18,7 +18,7 @@ {% block content %}