Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ __pycache__
.log
tmp/

# Frontend, which we serve from Django
reactapp/

# Script for debugging purposes.
root/scripts/testing.py

Expand Down
19 changes: 17 additions & 2 deletions backend/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ pillow = "11.*"
gunicorn = "23.*"
django-admin-autocomplete-filter = "0.*"
psycopg = { extras = ["c"], version = "*" }
whitenoise = "^6.11.0"


[tool.poetry.group.dev.dependencies]
Expand Down
11 changes: 10 additions & 1 deletion backend/root/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent

# React frontend
REACT_BUILD_DIR = "reactapp"

IS_DOCKER = os.environ.get('IS_DOCKER') == 'yes'

# Load '.env'.
Expand All @@ -40,7 +43,12 @@

# Static
STATIC_ROOT = BASE_DIR / 'staticroot'
STATIC_URL = '/static/'
STATIC_URL = '/assets/'

STATICFILES_DIRS = [
BASE_DIR / REACT_BUILD_DIR,
BASE_DIR / REACT_BUILD_DIR / 'assets',
]

# Media
MEDIA_ROOT = BASE_DIR / 'mediaroot'
Expand Down Expand Up @@ -93,6 +101,7 @@
MIDDLEWARE = [
'root.custom_classes.middlewares.RequestLogMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
Expand Down
Empty file.
96 changes: 96 additions & 0 deletions backend/samfundet/routing/frontend_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
THIS FILE IS AUTOGENERATED!
DO NOT WRITE IN THIS FILE, AS IT WILL BE OVERWRITTEN ON NEXT UPDATE.

Frontend routes meant for consumption by the Django backend.

Run 'yarn export-routes' in the frontend directory to regenerate
"""

HOME = "/"
GANGS = "/gangs/"
HEALTH = "/health/"
ABOUT = "/about/"
VENUES = "/venues/"
LOGIN = "/login/"
NEW_LOGIN = "/new-login/"
SIGNUP = "/signup/"
EVENTS = "/events/"
EVENT = "/events/<id>/"
INFORMATION_PAGE_LIST = "/information/"
INFORMATION_PAGE_DETAIL = "/information/<slugField>/"
SAKSDOKUMENTER = "/saksdokumenter/"
MEMBERSHIP = "/membership"
LUKA = "/luka"
CONTRIBUTORS = "/contributors"
RECRUITMENT = "/recruitment/"
RECRUITMENT_APPLICATION = "/recruitment/<recruitmentId>/position/<positionId>/"
RECRUITMENT_APPLICATION_OVERVIEW = "/recruitment/<recruitmentId>/my-applications/"
ORGANIZATION_RECRUITMENT = "/recruitment/<recruitmentId>/"
CONTACT = "/contact"
PURCHASE_CALLBACK = "/purchase-callback/<eventId>"
LYCHE = "/lyche/"
SULTEN = "/lyche"
SULTEN_MENU = "/lyche/menu/"
SULTEN_RESERVATION = "/lyche/reservation/"
SULTEN_ABOUT = "/lyche/about/"
SULTEN_CONTACT = "lyche/contact/"
USER_CHANGE_PASSWORD = "/control-panetl/password/"
ADMIN = "/control-panel/"
ADMIN_USERS = "/control-panel/users/"
ADMIN_ROLES = "/control-panel/roles/"
ADMIN_ROLES_VIEW = "/control-panel/roles/<roleId>/"
ADMIN_ROLES_EDIT = "/control-panel/roles/<roleId>/edit/"
ADMIN_ROLES_CREATE = "/control-panel/roles/create/"
ADMIN_GANGS = "/control-panel/gangs/"
ADMIN_GANGS_CREATE = "/control-panel/gangs/create/"
ADMIN_GANGS_EDIT = "/control-panel/gangs/edit/<gangId>/"
ADMIN_EVENTS = "/control-panel/events/"
ADMIN_EVENTS_EDIT = "/control-panel/events/edit/<id>/"
ADMIN_EVENTS_CREATE = "/control-panel/events/create/"
ADMIN_INFORMATION = "/control-panel/information/"
ADMIN_INFORMATION_EDIT = "/control-panel/information/edit/<slugField>/"
ADMIN_INFORMATION_CREATE = "/control-panel/information/create/"
ADMIN_OPENING_HOURS = "/control-panel/opening-hours/"
ADMIN_CLOSED = "/control-panel/closed/"
ADMIN_CLOSED_CREATE = "/control-panel/closed/create/"
ADMIN_CLOSED_EDIT = "/control-panel/closed/edit/<id>/"
ADMIN_IMAGES = "/control-panel/images/"
ADMIN_IMAGES_CREATE = "/control-panel/images/create/"
ADMIN_SAKSDOKUMENTER = "/control-panel/saksdokument/"
ADMIN_SAKSDOKUMENTER_CREATE = "/control-panel/saksdokument/create/"
ADMIN_SAKSDOKUMENTER_EDIT = "/control-panel/saksdokument/edit/<id>/"
ADMIN_RECRUITMENT = "/control-panel/recruitment/"
ADMIN_RECRUITMENT_EDIT = "/control-panel/recruitment/edit/<recruitmentId>"
ADMIN_RECRUITMENT_CREATE = "/control-panel/recruitment/create/"
ADMIN_RECRUITMENT_USERS_THREE_INTERVIEW_CRITERIA = "/control-panel/recruitment/<recruitmentId>/users-without-three-interviews/"
ADMIN_RECRUITMENT_USERS_WITHOUT_INTERVIEW = "/control-panel/recruitment/<recruitmentId>/users-without-applications/"
ADMIN_RECRUITMENT_OPEN_TO_OTHER_POSITIONS = "/control-panel/recruitment/<recruitmentId>/users-open-to-other-positions/"
ADMIN_RECRUITMENT_OVERVIEW = "/control-panel/recruitment/<recruitmentId>/recruitment-overview/"
ADMIN_RECRUITMENT_GANG_OVERVIEW = "/control-panel/recruitment/<recruitmentId>/gang-overview/"
ADMIN_RECRUITMENT_GANG_OVERVIEW_REJECTION_EMAIL = "/control-panel/recruitment/<recruitmentId>/gang-overview/rejection-email/"
ADMIN_RECRUITMENT_GANG_POSITION_OVERVIEW = "/control-panel/recruitment/<recruitmentId>/gang/<gangId>"
ADMIN_RECRUITMENT_GANG_POSITION_CREATE = "/control-panel/recruitment/<recruitmentId>/gang/<gangId>/create/"
ADMIN_RECRUITMENT_GANG_POSITION_EDIT = "/control-panel/recruitment/<recruitmentId>/gang/<gangId>/edit/<positionId>"
ADMIN_RECRUITMENT_GANG_SEPARATEPOSITION_CREATE = "/control-panel/recruitment/<recruitmentId>/separateposition/create"
ADMIN_RECRUITMENT_GANG_SEPARATEPOSITION_EDIT = "/control-panel/recruitment/<recruitmentId>/separateposition/edit/<separatePositionId>"
ADMIN_RECRUITMENT_RECRUITER_DASHBOARD = "/control-panel/recruitment/<recruitmentId>/recruiter/dashboard/"
ADMIN_RECRUITMENT_ROOM_OVERVIEW = "/control-panel/recruitment/<recruitmentId>/room-overview/"
ADMIN_RECRUITMENT_ROOM_CREATE = "/control-panel/recruitment/<recruitmentId>/room/create/"
ADMIN_RECRUITMENT_ROOM_EDIT = "/control-panel/recruitment/<recruitmentId>/room/edit/<roomId>/"
ADMIN_RECRUITMENT_GANG_POSITION_APPLICANTS_OVERVIEW = "/control-panel/recruitment/<recruitmentId>/gang/<gangId>/position/<positionId>"
ADMIN_RECRUITMENT_GANG_POSITION_APPLICANTS_INTERVIEW_NOTES = "/control-panel/recruitment/<recruitmentId>/gang/<gangId>/position/<positionId>/interview-notes/<interviewId>"
ADMIN_RECRUITMENT_GANG_ALL_APPLICATIONS = "/control-panel/recruitment/<recruitmentId>/<gangId>/all-applications/"
ADMIN_RECRUITMENT_GANG_USERS_WITHOUT_INTERVIEW = "/control-panel/recruitment/<recruitmentId>/<gangId>/users-without-interviews/"
ADMIN_RECRUITMENT_SHOW_UNPROCESSED_APPLICANTS = "/control-panel/recruitment/<recruitmentId>/unprocessed-applicants/"
ADMIN_RECRUITMENT_INTERVIEW_AVAILABILITY = "/control-panel/recruitment/<recruitmentId>/interview-availability/"
ADMIN_SULTEN_MENU = "/control-panel/lyche/menu"
ADMIN_SULTEN_MENUITEM_CREATE = "/control-panel/lyche/menuitems/create"
ADMIN_SULTEN_MENUITEM_EDIT = "/control-panel/lyche/menuitems/edit/<id>"
ADMIN_SULTEN_RESERVATIONS = "/control-panel/lyche/reservations"
ADMIN_RECRUITMENT_APPLICANT = "/control-panel/recruitment/view-applicant/<applicationID>/"
ADMIN_RECRUITMENT_ALL_POSITIONS = "/control-panel/recruitment/<recruitmentId>/all-positions/"
API_TESTING = "/api-testing/"
COMPONENTS = "/components/"
ROUTE_OVERVIEW = "/route/overview/"
NOT_FOUND = "/not-found"
59 changes: 59 additions & 0 deletions backend/samfundet/routing/metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import re
from dataclasses import dataclass, field
from typing import Optional, List
from django.utils.html import escape


@dataclass
class MetadataItem:
attr: str
key: str
value: str


@dataclass
class Metadata:
title: Optional[str] = None
description: Optional[str] = None
canonical_url: Optional[str] = None
items: List[MetadataItem] = field(default_factory=list)


def build_metadata_html(metadata: Metadata) -> str:
tags = []

if metadata.title:
metadata.items.append(MetadataItem("property", "og:title", metadata.title))
metadata.items.append(MetadataItem("name", "twitter:title", metadata.title))

if metadata.description:
tags.append(f'<meta name="description" content="{escape(metadata.description)}">')
metadata.items.append(MetadataItem("property", "og:description", metadata.description))

if metadata.canonical_url:
tags.append(f'<link rel="canonical" href="{escape(metadata.canonical_url)}">')

for item in metadata.items:
tags.append(f'<meta {item.attr}="{escape(item.key)}" content="{escape(item.value)}">')

return "\n".join(tags)


def inject_metadata(html: str, metadata: Metadata) -> str:
if metadata.title:
title = escape(metadata.title)
if re.search(r"<title>.*?</title>", html, flags=re.IGNORECASE | re.DOTALL):
html = re.sub(
r"<title>.*?</title>",
f"<title>{title}</title>",
html,
flags=re.IGNORECASE | re.DOTALL
)
else:
html = html.replace("</head>", f"<title>{title}</title></head>")

meta_html = build_metadata_html(metadata)

html = html.replace("</head>", f"{meta_html}\n</head>")

return html
54 changes: 54 additions & 0 deletions backend/samfundet/routing/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import os

from django.conf import settings
from django.http import HttpResponse
from rest_framework.generics import get_object_or_404

from samfundet.models import Event
from .metadata import Metadata, MetadataItem, inject_metadata


def get_frontend_html() -> str:
path = os.path.join(settings.REACT_BUILD_DIR, 'index.html')
with open(path, 'r') as f:
return f.read()


def react_view(request, **kwargs):
"""
Serve the built React index.html
All route parameters from URL are captured in kwargs but not used
(React Router will handle routing on the client side)
"""
try:
return HttpResponse(get_frontend_html())
except FileNotFoundError:
return HttpResponse("React build not found.", status=500)


def is_bot(request):
return True
bot_patterns = [
'googlebot', 'bingbot', 'slurp', 'duckduckbot',
'baiduspider', 'yandexbot', 'facebookexternalhit',
'twitterbot', 'linkedinbot', 'whatsapp', 'telegrambot',
'claudebot', 'perplexitybot', 'chatgpt', 'pinterestbot',
]
user_agent = request.META['HTTP_USER_AGENT'].lower()
return any(pattern in user_agent for pattern in bot_patterns)


def react_event_view(request, **kwargs):
if is_bot(request):
event = get_object_or_404(Event, id=kwargs["id"])

title = f"{event.title_nb} - Samfundet"
description = event.description_short_nb

metadata = Metadata(title=title, description=description)
metadata.items.append(MetadataItem("name", "og:custom", "Lorem ipsum"))

html = get_frontend_html()
return HttpResponse(inject_metadata(html, metadata))

return react_view(request, **kwargs)
20 changes: 18 additions & 2 deletions backend/samfundet/urls.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# imports
from __future__ import annotations

from django.conf.urls.static import static
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView

from rest_framework import routers

from django.urls import path, include
from django.conf import settings

import samfundet.view.user_views
import samfundet.view.event_views
Expand All @@ -14,7 +16,9 @@
from samfundet.view import billig_views

from . import views
from .routing.views import react_view, react_event_view
from .view import recruitment_views
from .routing import frontend_routes

# End: imports -----------------------------------------------------------------
router = routers.DefaultRouter()
Expand Down Expand Up @@ -68,7 +72,11 @@

app_name = 'samfundet'

urlpatterns = [
def frontend_path(route_path, view, name):
return path(route_path.lstrip('/'), view, name=name)


urlpatterns = ([
path('api/', include(router.urls)),
path('schema/', SpectacularAPIView.as_view(), name='schema'),
path('schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='samfundet:schema'), name='swagger_ui'),
Expand Down Expand Up @@ -185,4 +193,12 @@
path('recruitment/<int:recruitment_id>/gang/<int:gang_id>/stats/', views.GangApplicationCountView.as_view(), name='gang-application-stats'),
path('recruitment/<int:id>/positions-by-tags/', views.PositionByTagsView.as_view(), name='recruitment_positions_by_tags'),
path('recruitment/all-applications/', views.RecruitmentAllApplicationsPerRecruitmentView.as_view(), name='recruitment-all-applications'),
]

*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
] + [
# Serve React frontend
frontend_path(frontend_routes.EVENT, react_event_view, name='reactapp_event'),

path("", react_view, name="reactapp"),
path("<path:resource>", react_view),#
])
9 changes: 9 additions & 0 deletions frontend/build_to_backend.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash

set -eu

yarn build --mode development
rm -rf ../backend/reactapp
mv dist ../backend/reactapp

echo "Done!"
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://use.typekit.net/fha6pnq.css">
<title>Samfundet</title>
Expand Down
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"stylelint:check": "stylelint --config .stylelintrc src/**/*.{css,scss}",
"tsc:check": "tsc",
"tsc:watch": "tsc --watch",
"verify": "yarn biome:check && yarn tsc:check && yarn stylelint:check"
"verify": "yarn biome:check && yarn tsc:check && yarn stylelint:check",
"export-routes": "tsx scripts/export-routes.ts"
},
"dependencies": {
"@babel/core": "^7.23.2",
Expand Down Expand Up @@ -103,6 +104,7 @@
"stylelint-config-standard": "^34.0.0",
"stylelint-config-standard-scss": "^11.0.0",
"stylelint-scss": "^5.2.1",
"tsx": "^4.20.6",
"typescript": "^5.2.2",
"vite": "^4.5.14"
},
Expand Down
Loading