diff --git a/.env.example b/.env.example index 4b6958a..6ea73c4 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,15 @@ OIDC_OP_JWKS_ENDPOINT= OIDC_RP_CLIENT_ID= OIDC_RP_CLIENT_SECRET= OIDC_RP_SIGN_ALGO= # example: RS256 -OIDC_RP_SCOPES= # example: openid email profile \ No newline at end of file +OIDC_RP_SCOPES= # example: openid email profile +OIDC_SUPERUSER_GROUP= # OIDC group giving Django staff+superuser +OIDC_EDITOR_GROUP= # OIDC group granting staff + editor Django group +OIDC_EDITOR_DJANGO_GROUP= # Django group name (example: Editeur BDE) + +# Logging / debugging +DJANGO_LOG_LEVEL= # example: DEBUG, INFO, WARNING, ERROR +OIDC_LOG_LEVEL= # example: DEBUG, INFO +GUNICORN_LOG_LEVEL= # example: debug, info, warning, error +GUNICORN_WORKERS= # example: 2 +GUNICORN_THREADS= # example: 2 +GUNICORN_TIMEOUT= # example: 120 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e9b5805..8051ff0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -49,12 +49,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - - name: Check for missing migrations - run: python manage.py makemigrations --check --dry-run - - name: Wait for MariaDB run: | - for i in {1..30}; do + for i in {1..60}; do if mysqladmin ping -h127.0.0.1 -utestuser -ptestpass --silent; then echo "MariaDB is up!" exit 0 @@ -74,6 +71,15 @@ jobs: echo "DB_PORT=3306" >> $GITHUB_ENV echo "SECRET_KEY=testsecrret" >> $GITHUB_ENV + - name: Check for missing migrations + env: + DJANGO_DB_NAME: ${{ env.DB_NAME }} + DJANGO_DB_USER: ${{ env.DB_USER }} + DJANGO_DB_PASSWORD: ${{ env.DB_PASSWORD }} + DJANGO_DB_HOST: ${{ env.DB_HOST }} + DJANGO_DB_PORT: ${{ env.DB_PORT }} + run: python manage.py makemigrations --check --dry-run + - name: Run Django tests env: DJANGO_DB_NAME: ${{ env.DB_NAME }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..57a8e3e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: local + hooks: + - id: django-migrations-check + name: Check Django migrations + entry: .venv/bin/python manage.py makemigrations --check --dry-run + language: system + pass_filenames: false + - id: django-tests + name: Run Django tests + entry: .venv/bin/python manage.py test --no-input + language: system + pass_filenames: false + - id: lint + name: Run linters + entry: .venv/bin/python -m mypy --config-file mypy.ini . + language: system + pass_filenames: false \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 11442b7..07cf084 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,4 @@ RUN python manage.py collectstatic --noinput EXPOSE 8000 -CMD ["sh", "-c", "python manage.py migrate && python -m bde.generate_robots && gunicorn bde.wsgi:application --bind 0.0.0.0:8000"] +CMD ["sh", "-c", "python manage.py migrate && python -m bde.generate_robots && gunicorn -c bde/gunicorn.conf.py bde.wsgi:application"] diff --git a/README.md b/README.md index f26546d..ef4a0cd 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,16 @@ mypy . docker compose logs -f web ``` +## Pre-commit + +```bash +# depuis un shell avec le venv actif +pre-commit install +pre-commit run --all-files +``` + +Les hooks locaux utilisent le Python du projet (`.venv/bin/python`) pour les checks Django et `mypy`. + ## Arborescence principale - `bde/` : configuration Django (settings, urls, env) diff --git a/auth/auth_backends.py b/auth/auth_backends.py index 9e9ef82..944b643 100644 --- a/auth/auth_backends.py +++ b/auth/auth_backends.py @@ -1,29 +1,142 @@ +# You're lucky, I added some comments to this file to explain the custom logic. +# Maybe because it's not trivial (that's the least we can say...) +# So, have a good read ! +# Arthur + +# Some imports (trivial for now) +import logging + +from django.conf import settings +from django.contrib.auth.models import Group from mozilla_django_oidc.auth import ( # type: ignore[import-untyped] OIDCAuthenticationBackend, ) +logger = logging.getLogger(__name__) + + class CustomOIDCBackend(OIDCAuthenticationBackend): + + # Normalize the groups claim so downstream checks always work on a list[str]. + @staticmethod + def _extract_groups(claims): + groups = claims.get("groups", []) + if isinstance(groups, str): + return [groups] + if isinstance(groups, list): + return [str(group) for group in groups] + logger.warning("OIDC claims 'groups' has unsupported type: %s", type(groups)) + return [] + + @staticmethod + def _claims_context(claims): + return { + "sub": claims.get("sub"), + "preferred_username": claims.get("preferred_username"), + "email": claims.get("email"), + "name": claims.get("name"), + } + + # Synchronize Django access flags and group membership from OIDC claims. + # Rules: + # - superuser comes only from OIDC_SUPERUSER_GROUP + # - staff comes from superuser OR editor OIDC group + # - editor Django group membership mirrors editor OIDC group presence + def _sync_oidc_access(self, user, claims): + oidc_groups = self._extract_groups(claims) + + # Names are configured through environment-backed Django settings. + superuser_group = getattr(settings, "OIDC_SUPERUSER_GROUP", "") + editor_group = getattr(settings, "OIDC_EDITOR_GROUP", "") + editor_django_group_name = getattr(settings, "OIDC_EDITOR_DJANGO_GROUP", "") + + is_superuser_from_oidc = ( + bool(superuser_group) and superuser_group in oidc_groups + ) + has_editor_from_oidc = bool(editor_group) and editor_group in oidc_groups + + if not superuser_group: + logger.warning( + "OIDC_SUPERUSER_GROUP is not configured; superuser flag will be removed" + ) + + # Keep Django privilege flags aligned with OIDC source of truth. + user.is_superuser = is_superuser_from_oidc + user.is_staff = is_superuser_from_oidc or has_editor_from_oidc + + if not editor_django_group_name: + logger.warning( + "OIDC_EDITOR_DJANGO_GROUP is not configured; cannot grant editor group" + ) + return + + try: + django_editor_group = Group.objects.get(name=editor_django_group_name) + except Group.DoesNotExist: + logger.warning( + "Configured Django editor group '%s' does not exist", + editor_django_group_name, + ) + return + + if not editor_group: + logger.warning( + "OIDC_EDITOR_GROUP is not configured; removing editor Django group" + ) + + # Add or remove the editor Django group on every login to avoid drift. + if has_editor_from_oidc: + user.groups.add(django_editor_group) + else: + user.groups.remove(django_editor_group) + + # First login path: create local user, then apply profile and access sync. def create_user(self, claims): - user = super().create_user(claims) + logger.debug("OIDC create_user start: %s", self._claims_context(claims)) + try: + user = super().create_user(claims) - name = claims.get("name", user.username) + name = claims.get("name", user.username) - user.username = claims.get("preferred_username", user.username) - user.first_name = name.split(" ")[0] if " " in name else name - user.last_name = " ".join(name.split(" ")[1:]) if " " in name else "" - user.email = claims.get("email", "") - user.save() + user.username = claims.get("preferred_username", user.username) + user.first_name = name.split(" ")[0] if " " in name else name + user.last_name = " ".join(name.split(" ")[1:]) if " " in name else "" + user.email = claims.get("email", "") + self._sync_oidc_access(user, claims) + user.save() - return user + logger.info("OIDC create_user success for username=%s", user.username) + return user + except Exception: + logger.exception( + "OIDC create_user failed: %s", self._claims_context(claims) + ) + raise + # Subsequent logins: refresh profile and access rules from OIDC. def update_user(self, user, claims): - name = claims.get("name", user.username) + logger.debug( + "OIDC update_user start for username=%s claims=%s", + user.username, + self._claims_context(claims), + ) + try: + name = claims.get("name", user.username) - user.username = claims.get("preferred_username", user.username) - user.first_name = name.split(" ")[0] if " " in name else name - user.last_name = " ".join(name.split(" ")[1:]) if " " in name else "" - user.email = claims.get("email", "") - user.save() + user.username = claims.get("preferred_username", user.username) + user.first_name = name.split(" ")[0] if " " in name else name + user.last_name = " ".join(name.split(" ")[1:]) if " " in name else "" + user.email = claims.get("email", "") + self._sync_oidc_access(user, claims) + user.save() - return user + logger.info("OIDC update_user success for username=%s", user.username) + return user + except Exception: + logger.exception( + "OIDC update_user failed for username=%s claims=%s", + user.username, + self._claims_context(claims), + ) + raise diff --git a/bde/env.py b/bde/env.py index 8c5f911..1aac952 100644 --- a/bde/env.py +++ b/bde/env.py @@ -115,3 +115,27 @@ def OIDC_RP_SIGN_ALGO(self) -> str: @property def OIDC_RP_SCOPES(self) -> str: return self.get("OIDC_RP_SCOPES", "") + + @property + def OIDC_SUPERUSER_GROUP(self) -> str: + return self.get("OIDC_SUPERUSER_GROUP", "") + + @property + def OIDC_EDITOR_GROUP(self) -> str: + return self.get("OIDC_EDITOR_GROUP", "") + + @property + def OIDC_EDITOR_DJANGO_GROUP(self) -> str: + return self.get("OIDC_EDITOR_DJANGO_GROUP", "") + + @property + def DJANGO_LOG_LEVEL(self) -> str: + return self.get("DJANGO_LOG_LEVEL", "INFO") + + @property + def OIDC_LOG_LEVEL(self) -> str: + return self.get("OIDC_LOG_LEVEL", "DEBUG") + + @property + def GUNICORN_LOG_LEVEL(self) -> str: + return self.get("GUNICORN_LOG_LEVEL", "debug") diff --git a/bde/generate_robots.py b/bde/generate_robots.py index 769a1d1..4299b08 100644 --- a/bde/generate_robots.py +++ b/bde/generate_robots.py @@ -7,19 +7,21 @@ def get_robots_content() -> str: if env.DEV_MODE: - return "User-Agent: *\nDisallow: /" + return "User-Agent: *\nDisallow: /\nSitemap: /sitemap.xml\n" - return f"""User-agent: * - Disallow: /{env.ADMIN_URL} - Disallow: /logout/ - Disallow: /redirect/ - Disallow: /sso/ - Disallow: /uploads/ - """ + return ( + f"User-agent: *\n" + f"Disallow: /{env.ADMIN_URL}\n" + "Disallow: /logout/\n" + "Disallow: /redirect/\n" + "Disallow: /sso/\n" + "Disallow: /uploads/\n" + "Sitemap: /sitemap.xml\n" + ) BASE_DIR = Path(__file__).resolve().parent.parent -STATICFILES_DIR = BASE_DIR / ("staticfiles" if not env.DEBUG else "static") +STATICFILES_DIR = BASE_DIR / "staticfiles" STATICFILES_DIR.mkdir(exist_ok=True) robots_path = STATICFILES_DIR / "robots.txt" diff --git a/bde/gunicorn.conf.py b/bde/gunicorn.conf.py new file mode 100644 index 0000000..62d2e5c --- /dev/null +++ b/bde/gunicorn.conf.py @@ -0,0 +1,18 @@ +import os + + +bind = "0.0.0.0:8000" +workers = int(os.getenv("GUNICORN_WORKERS", "2")) +threads = int(os.getenv("GUNICORN_THREADS", "2")) +timeout = int(os.getenv("GUNICORN_TIMEOUT", "120")) + +accesslog = "-" +errorlog = "-" +loglevel = os.getenv("GUNICORN_LOG_LEVEL", "debug") +capture_output = True +enable_stdio_inheritance = True + +access_log_format = ( + '%(h)s %(l)s %(u)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" ' + "rt=%(M)sms req_id=%({x-request-id}i)s fwd=%({x-forwarded-for}i)s" +) diff --git a/bde/settings.py b/bde/settings.py index cecc4c5..6530b55 100644 --- a/bde/settings.py +++ b/bde/settings.py @@ -14,6 +14,10 @@ SESSION_COOKIE_SECURE = env.SESSION_COOKIE_SECURE CSRF_COOKIE_SECURE = env.CSRF_COOKIE_SECURE +DEFAULT_SEO_TITLE = "BDE UTT" +DEFAULT_SEO_DESCRIPTION = "Le site du Bureau des Etudiants de l'UTT : evenements, services, partenariats et vie associative." +DEFAULT_SEO_IMAGE = "/static/img/bde.png" + if env.CSRF_TRUSTED_ORIGINS: CSRF_TRUSTED_ORIGINS = env.CSRF_TRUSTED_ORIGINS @@ -24,6 +28,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.sitemaps", "mozilla_django_oidc", "core", "members", @@ -59,6 +64,9 @@ OIDC_RP_CLIENT_SECRET = env.OIDC_RP_CLIENT_SECRET OIDC_RP_SIGN_ALGO = env.OIDC_RP_SIGN_ALGO OIDC_RP_SCOPES = env.OIDC_RP_SCOPES +OIDC_SUPERUSER_GROUP = env.OIDC_SUPERUSER_GROUP +OIDC_EDITOR_GROUP = env.OIDC_EDITOR_GROUP +OIDC_EDITOR_DJANGO_GROUP = env.OIDC_EDITOR_DJANGO_GROUP ROOT_URLCONF = "bde.urls" @@ -131,6 +139,46 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": {"format": "%(asctime)s %(levelname)s %(name)s %(message)s"}, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": env.DJANGO_LOG_LEVEL, + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": env.DJANGO_LOG_LEVEL, + "propagate": False, + }, + "django.request": { + "handlers": ["console"], + "level": "ERROR", + "propagate": False, + }, + "mozilla_django_oidc": { + "handlers": ["console"], + "level": env.OIDC_LOG_LEVEL, + "propagate": False, + }, + "auth.auth_backends": { + "handlers": ["console"], + "level": env.OIDC_LOG_LEVEL, + "propagate": False, + }, + }, +} + # Django Browser Reload (for development only) if DEBUG: INSTALLED_APPS += [ diff --git a/bde/sitemaps.py b/bde/sitemaps.py new file mode 100644 index 0000000..c1f3028 --- /dev/null +++ b/bde/sitemaps.py @@ -0,0 +1,27 @@ +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + + +class StaticViewSitemap(Sitemap): + changefreq = "weekly" + priority = 0.8 + + def items(self): + return [ + "home", + "contacts", + "events", + "membership", + "partners", + "services", + "members", + "board", + ] + + def location(self, item): + return reverse(item) + + +sitemaps = { + "static": StaticViewSitemap, +} diff --git a/bde/templates/legals.html b/bde/templates/legals.html index 1c4d0e5..4f96e83 100644 --- a/bde/templates/legals.html +++ b/bde/templates/legals.html @@ -22,11 +22,11 @@
BDE UTT
Association loi 1901
-N° RNA :
-N° RCS :
+N° RNA : W103000735
+N° SIRET : 44838667200019
+N° SIREN : 448386672
diff --git a/bde/tests.py b/bde/tests.py new file mode 100644 index 0000000..a7127de --- /dev/null +++ b/bde/tests.py @@ -0,0 +1,18 @@ +from django.test import SimpleTestCase, override_settings + +from bde.generate_robots import get_robots_content + + +class SEOInfrastructureTests(SimpleTestCase): + def test_sitemap_is_available(self): + response = self.client.get("/sitemap.xml") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "/members/") + self.assertContains(response, "/events/") + + @override_settings(DEBUG=True) + def test_robots_declares_sitemap(self): + robots = get_robots_content() + + self.assertIn("Sitemap: /sitemap.xml", robots) diff --git a/bde/urls.py b/bde/urls.py index 7411b54..90b6cc9 100644 --- a/bde/urls.py +++ b/bde/urls.py @@ -1,10 +1,12 @@ from django.contrib import admin +from django.contrib.sitemaps.views import sitemap from django.urls import path, include, re_path from django.conf import settings from django.conf.urls.static import static from django.views.static import serve from bde.env import EnvConfig +from bde.sitemaps import sitemaps env = EnvConfig() ADMIN_URL = env.ADMIN_URL @@ -22,6 +24,12 @@ serve, {"path": "robots.txt", "document_root": settings.STATIC_ROOT}, ), + path( + "sitemap.xml", + sitemap, + {"sitemaps": sitemaps}, + name="django.contrib.sitemaps.views.sitemap", + ), path("sso/", include("mozilla_django_oidc.urls")), path(ADMIN_URL, admin.site.urls), ] diff --git a/bde/views.py b/bde/views.py index a8bdd2c..1285a23 100644 --- a/bde/views.py +++ b/bde/views.py @@ -21,7 +21,15 @@ def legals(request): return render( request, "legals.html", - {**common_data()}, + { + **common_data( + request, + seo={ + "title": "BDE UTT | Mentions legales", + "description": "Mentions legales du site du Bureau des Etudiants de l'UTT.", + }, + ) + }, ) @@ -29,5 +37,13 @@ def privacy(request): return render( request, "privacy.html", - {**common_data()}, + { + **common_data( + request, + seo={ + "title": "BDE UTT | Politique de confidentialite", + "description": "Politique de confidentialite et traitement des donnees personnelles du site BDE UTT.", + }, + ) + }, ) diff --git a/docker-compose.yaml b/docker-compose.yaml index 441feaf..477d510 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ services: web: build: . - command: sh -c "python manage.py migrate && python -m bde.generate_robots && gunicorn bde.wsgi:application --bind 0.0.0.0:8000" + command: sh -c "python manage.py migrate && python -m bde.generate_robots && gunicorn -c bde/gunicorn.conf.py bde.wsgi:application" volumes: - .:/app - ./uploads:/app/uploads diff --git a/members/admin.py b/members/admin.py index 6afa392..15582ed 100644 --- a/members/admin.py +++ b/members/admin.py @@ -14,4 +14,4 @@ class UserTeamAdmin(admin.ModelAdmin): @admin.register(UserProfile) class UserProfileAdmin(admin.ModelAdmin): - list_display = ("user", "picture", "description") + list_display = ("user", "picture", "feminine_role", "description") diff --git a/members/migrations/0004_userprofile_feminine_role_alter_userteam_role.py b/members/migrations/0004_userprofile_feminine_role_alter_userteam_role.py new file mode 100644 index 0000000..093fed4 --- /dev/null +++ b/members/migrations/0004_userprofile_feminine_role_alter_userteam_role.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2026-03-08 15:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0003_alter_userteam_team'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='feminine_role', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='userteam', + name='role', + field=models.CharField(choices=[('PRESIDENT', 'Président'), ('VICE_PRESIDENT', 'Vice-Président'), ('SECRETARY', 'Secrétaire'), ('TREASURER', 'Trésorier'), ('VICE_TREASURER', 'Vice-Trésorier'), ('MANAGER', 'Responsable'), ('MEMBER', 'Membre')], default='MEMBER', max_length=20), + ), + ] diff --git a/members/migrations/0005_alter_userteam_role.py b/members/migrations/0005_alter_userteam_role.py new file mode 100644 index 0000000..c369582 --- /dev/null +++ b/members/migrations/0005_alter_userteam_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2026-03-08 23:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0004_userprofile_feminine_role_alter_userteam_role'), + ] + + operations = [ + migrations.AlterField( + model_name='userteam', + name='role', + field=models.CharField(choices=[('PRESIDENT', 'Président'), ('TREASURER', 'Trésorier'), ('VICE_TREASURER', 'Vice-Trésorier'), ('VICE_PRESIDENT', 'Vice-Président'), ('SECRETARY', 'Secrétaire'), ('MANAGER', 'Responsable'), ('MEMBER', 'Membre')], default='MEMBER', max_length=20), + ), + ] diff --git a/members/models.py b/members/models.py index 749f13a..b126af5 100644 --- a/members/models.py +++ b/members/models.py @@ -1,15 +1,42 @@ +from __future__ import annotations + from django.db import models from django.contrib.auth.models import User from django.utils.safestring import mark_safe from utils.models import picture_upload_to -from typing import cast +from typing import Literal, TypedDict, cast + + +RoleCode = Literal[ + "PRESIDENT", + "VICE_PRESIDENT", + "SECRETARY", + "TREASURER", + "VICE_TREASURER", + "MANAGER", + "MEMBER", +] + + +class TeamTemplate(TypedDict): + name: str + description: str + + +class UserTeamTemplate(TypedDict): + first_name: str + last_name: str + role: RoleCode + role_display: str + role_suffix: str + profile: "UserProfile | None" class Team(models.Model): name: models.CharField = models.CharField(max_length=100) description: models.TextField = models.TextField(blank=True) - def to_template(self) -> dict[str, str]: + def to_template(self) -> TeamTemplate: return {"name": self.name, "description": self.description} def __str__(self) -> str: @@ -27,6 +54,7 @@ def _members_picture_upload_to(instance, _) -> str: ) description: models.TextField = models.TextField(blank=True) + feminine_role: models.BooleanField = models.BooleanField(default=False) @property def picture_url(self) -> str: @@ -39,11 +67,19 @@ def __str__(self) -> str: class UserTeam(models.Model): - ROLES = [ + FEMINIZABLE_ROLES: set[RoleCode] = { + "PRESIDENT", + "VICE_PRESIDENT", + "TREASURER", + "VICE_TREASURER", + } + + ROLES: list[tuple[RoleCode, str]] = [ ("PRESIDENT", "Président"), + ("TREASURER", "Trésorier"), + ("VICE_TREASURER", "Vice-Trésorier"), ("VICE_PRESIDENT", "Vice-Président"), ("SECRETARY", "Secrétaire"), - ("TREASURER", "Trésorier"), ("MANAGER", "Responsable"), ("MEMBER", "Membre"), ] @@ -60,13 +96,21 @@ class UserTeam(models.Model): def role_display(self) -> str: return dict(self.ROLES).get(self.role, "Unknown") - def to_template(self) -> dict[str, object | None]: + def role_suffix(self, profile: UserProfile | None) -> str: + if profile and profile.feminine_role and self.role in self.FEMINIZABLE_ROLES: + return "e" + return "" + + def to_template(self) -> UserTeamTemplate: user = cast(User, self.user) + profile = get_userProfile_from_userTeam(self) return { "first_name": user.first_name, "last_name": user.last_name, + "role": self.role, "role_display": self.role_display, - "profile": get_userProfile_from_userTeam(self), + "role_suffix": self.role_suffix(profile), + "profile": profile, } def __str__(self) -> str: diff --git a/members/templates/board/memberCard.html b/members/templates/board/memberCard.html index 5273d21..9376f82 100644 --- a/members/templates/board/memberCard.html +++ b/members/templates/board/memberCard.html @@ -9,7 +9,8 @@ alt="{{member.first_name}} {{member.last_name}}" style="width: 72px; height: 72px; object-fit: cover;">
{{member.role_display}}
+{{ member.role_display }}e
On recrute du monde !
+ {% else %} {% for member in team.members %}{{member.first_name}} {{member.last_name}}
-{{member.role_display}}
+{{ member.role_display }}e
Les SDF - Soirées Des Finaux, ce sont les soirées organisées pour les UTTiens à la toute fin du semestre, juste après les examens. Que ce soit pour célébrer la fin des partiels ou pour se consoler après une période intense, diff --git a/showcase/templates/home/main.html b/showcase/templates/home/main.html index 54d6e6e..ddb953c 100644 --- a/showcase/templates/home/main.html +++ b/showcase/templates/home/main.html @@ -10,13 +10,8 @@ {% include "partials/heroWithPicture.html" %} {% endwith %}{% endwith %}{% endwith %} - -
Afin de centraliser les demandes, nous avons mis en place une plateforme dédiée @@ -29,12 +26,69 @@
+ Pour tous les évènements organisés par les clubs et associations. + Permet également de réserver des salles / amphis. +
+ ⏱ Délais : avant le mercredi de la semaine précédante ++ Pour emprunter du matériel du BDE (crêpières, tente, matériel électrique, etc.). +
+ ⏱ Délais : au moins 48h avant ++ Demandes financières : remboursement, subvention, TPE, etc. +
+ ⏱ Délais : au moins 48h avant ++ Affichage sur écrans, diffusion dans les act'UTT. +
+ ⏱ Délais : avant le mercredi précédant la diffusion ++ Sonorisation et éclairage des évènements. +
+ ⏱ Délais : au moins 1 semaine avant +