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.first_name}} {{member.last_name}}
-

{{member.role_display}}

+

{{ member.role_display }}e

diff --git a/members/templates/members/cards/team.html b/members/templates/members/cards/team.html index a2325b7..db5a56f 100644 --- a/members/templates/members/cards/team.html +++ b/members/templates/members/cards/team.html @@ -6,8 +6,10 @@ {% block card_body %}
{{team.name}}
+ {% if team.members|length %} {{ team.members|length }} membre{{ team.members|length|pluralize }} + {% endif %}
{% if team.description %} @@ -17,15 +19,20 @@
{{team.name}}

+ {% if not team.members %} +

On recrute du monde !

+ {% else %} {% for member in team.members %}
{{member.first_name}} {{member.last_name}}

{{member.first_name}} {{member.last_name}}

-

{{member.role_display}}

+

{{ member.role_display }}e

{% endfor %} + {% endif %}
{% endblock %} \ No newline at end of file diff --git a/members/tests.py b/members/tests.py index 7bc936d..38a93a1 100644 --- a/members/tests.py +++ b/members/tests.py @@ -45,6 +45,10 @@ def test_common_data(self): response = self.client.get("/members/board/") self.assertIn("partners_qs", response.context) self.assertIn("partners_json", response.context) + self.assertIn("seo_title", response.context) + self.assertIn("seo_description", response.context) + self.assertIn("seo_canonical_url", response.context) + self.assertIn("seo_og_image", response.context) def test_members_context(self): response = self.client.get("/members/board/") @@ -117,6 +121,10 @@ def test_common_data(self): response = self.client.get("/members/") self.assertIn("partners_qs", response.context) self.assertIn("partners_json", response.context) + self.assertIn("seo_title", response.context) + self.assertIn("seo_description", response.context) + self.assertIn("seo_canonical_url", response.context) + self.assertIn("seo_og_image", response.context) def test_teams_context(self): response = self.client.get("/members/") diff --git a/members/views.py b/members/views.py index 4cbc4d5..9e5baf8 100644 --- a/members/views.py +++ b/members/views.py @@ -1,8 +1,12 @@ from django.shortcuts import render from django.contrib.auth.models import User -from .models import Team, UserTeam +from .models import RoleCode, Team, TeamTemplate, UserTeam, UserTeamTemplate from utils.views import common_data -from typing import cast +from typing import TypedDict, cast + + +class TeamWithMembersTemplate(TeamTemplate): + members: list[UserTeamTemplate] def board(request): @@ -11,11 +15,12 @@ def board(request): .filter(team__name="Bureau") .only("user__id", "user__first_name", "user__last_name", "role") ) - role_order = UserTeam.ROLES + role_order: list[RoleCode] = [role for role, _ in UserTeam.ROLES] members: list[UserTeam] = sorted( members_qs, key=lambda m: ( - role_order.index(m.role) if m.role in role_order else len(role_order) + role_order.index(m.role) if m.role in role_order else len(role_order), + cast(User, m.user).last_name, ), ) @@ -29,7 +34,13 @@ def board(request): request, "board/main.html", { - **common_data(), + **common_data( + request, + seo={ + "title": "BDE UTT | Bureau", + "description": "Decouvrez les membres du bureau du BDE UTT et leurs roles.", + }, + ), "members": [ { **member.to_template(), @@ -50,23 +61,40 @@ def members(request): "user", "role", "team" ) - teams_with_members = [ - { - **team.to_template(), - "members": [ - ut.to_template() - for ut in users_team - if cast(Team, ut.team).pk == team.pk - ], - } - for team in teams - ] + role_order: list[RoleCode] = [role for role, _ in UserTeam.ROLES] + + teams_with_members: list[TeamWithMembersTemplate] = sorted( + [ + { + **team.to_template(), + "members": sorted( + [ + ut.to_template() + for ut in users_team + if cast(Team, ut.team).pk == team.pk + ], + key=lambda m: ( + role_order.index(m["role"]), + m["last_name"].lower(), + ), + ), + } + for team in teams + ], + key=lambda t: t["name"].lower(), + ) return render( request, "members/main.html", { - **common_data(), + **common_data( + request, + seo={ + "title": "BDE UTT | Commissions", + "description": "Retrouvez les commissions du BDE UTT et les membres qui les composent.", + }, + ), "teams": teams_with_members, }, ) diff --git a/requirements.txt b/requirements.txt index 22c2c70..c3c4463 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,15 +2,20 @@ asgiref==3.9.1 astroid==3.3.11 certifi==2025.8.3 cffi==2.0.0 +cfgv==3.5.0 charset-normalizer==3.4.3 cryptography==45.0.7 defusedxml==0.7.1 dill==0.4.0 +distlib==0.4.0 Django==5.2.6 django-allauth==65.11.2 +django-browser-reload==1.21.0 django-stubs==5.2.2 django-stubs-ext==5.2.2 +filelock==3.25.0 gunicorn==23.0.0 +identify==2.6.17 idna==3.10 isort==6.0.1 josepy==2.1.0 @@ -19,18 +24,22 @@ mozilla-django-oidc==4.0.1 mypy==1.17.1 mypy_extensions==1.1.0 mysqlclient==2.2.7 +nodeenv==1.10.0 oauthlib==3.3.1 packaging==25.0 pathspec==0.12.1 pillow==11.3.0 platformdirs==4.4.0 +pre_commit==4.5.1 pycparser==2.23 pylint==3.3.8 pylint-django==2.6.1 pylint-plugin-utils==0.9.0 +python-discovery==1.1.1 python-dotenv==1.1.1 python-openid==2.2.5 python3-openid==3.2.0 +PyYAML==6.0.3 requests==2.32.5 requests-oauthlib==2.0.0 sqlparse==0.5.3 @@ -38,4 +47,5 @@ tomlkit==0.13.3 types-PyYAML==6.0.12.20250822 typing_extensions==4.15.0 urllib3==2.5.0 +virtualenv==21.1.0 whitenoise==6.10.0 diff --git a/showcase/templates/events/sdf/main.html b/showcase/templates/events/sdf/main.html index 3387604..5a3da37 100644 --- a/showcase/templates/events/sdf/main.html +++ b/showcase/templates/events/sdf/main.html @@ -12,9 +12,6 @@ {% include "partials/hero.html" %} {% endwith %}{% endwith %} - - {% include "partials/cards/construction.html" %} -

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 %} - -

- - {% include "partials/cards/construction.html" %} - {% include "home/cards/calendar.html" %} diff --git a/showcase/templates/services/clubs/main.html b/showcase/templates/services/clubs/main.html index efb936c..9b019d5 100644 --- a/showcase/templates/services/clubs/main.html +++ b/showcase/templates/services/clubs/main.html @@ -33,31 +33,6 @@
{{ contact.name }}
{% endfor %} -
-
-
Responsable de la Vie Etudiant et de Campus
- franck.jacquemin@utt.fr -
-
-
-
-
Direction du Numérique UTT
- dnum-support@utt.fr -
-
-
-
-
Service de Santé
- infirmerie@utt.fr -
-
-
-
-
Bureau des Etudiants
- Accéder à la page de contact -
-
diff --git a/showcase/templates/services/foyer/main.html b/showcase/templates/services/foyer/main.html index 6b0788e..2dba886 100644 --- a/showcase/templates/services/foyer/main.html +++ b/showcase/templates/services/foyer/main.html @@ -11,7 +11,6 @@ {% include "partials/heroWithPicture.html" %} {% endwith %}{% endwith %}{% endwith %} -
diff --git a/showcase/templates/services/tickets/main.html b/showcase/templates/services/tickets/main.html index fc82aa8..eea3df9 100644 --- a/showcase/templates/services/tickets/main.html +++ b/showcase/templates/services/tickets/main.html @@ -12,11 +12,8 @@ {% include "partials/hero.html" %} {% endwith %}{% endwith %} - - {% include "partials/cards/construction.html" %} -
-

C'est quoi ?

+ {% include "partials/title.html" with title="C'est quoi ?" %}

Afin de centraliser les demandes, nous avons mis en place une plateforme dédiée @@ -29,12 +26,69 @@

C'est quoi ?



Techniquement, la plateforme est basée sur Zammad, un logiciel de gestion de tickets open-source et hébergée - sur le Système d'Information des Association de l'UTT Net Group. Elle est accessible à l'adresse + sur le Système d'Information des Associations de l'UTT Net Group. Elle est accessible à l'adresse https://tickets.uttnetgroup.fr.

+ +
+ {% include "partials/title.html" with title="Quels types de tickets ?" %} + +
+
+ +
+
+
+ 🎫 Tickets Notification d'évènements + Obligatoire +
+
+

+ 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 +
+ +
+
⛺ Tickets Prêt de matériel
+

+ Pour emprunter du matériel du BDE (crêpières, tente, matériel électrique, etc.). +

+ ⏱ Délais : au moins 48h avant +
+ +
+
💰 Tickets Trésorerie
+

+ Demandes financières : remboursement, subvention, TPE, etc. +

+ ⏱ Délais : au moins 48h avant +
+ +
+
📢 Tickets Communication
+

+ Affichage sur écrans, diffusion dans les act'UTT. +

+ ⏱ Délais : avant le mercredi précédant la diffusion +
+ +
+
🎚️ Tickets Faders Son & Lumières
+

+ Sonorisation et éclairage des évènements. +

+ ⏱ Délais : au moins 1 semaine avant +
+ +
+
+
+

Comment accéder à son compte ?

diff --git a/showcase/templates/services/treasury/main.html b/showcase/templates/services/treasury/main.html index fd21340..64f931c 100644 --- a/showcase/templates/services/treasury/main.html +++ b/showcase/templates/services/treasury/main.html @@ -9,8 +9,6 @@

Trésorerie

- -
{% with title="Plateforme de contact Trésorerie" %} {% with subject="relatives à la trésorerie" %} diff --git a/showcase/tests.py b/showcase/tests.py index 4606d27..690e492 100644 --- a/showcase/tests.py +++ b/showcase/tests.py @@ -89,6 +89,14 @@ def _assert_common_data(self, response): self.assertIn("partners_qs", response.context) self.assertIn("partners_json", response.context) self.assertIn("current_year", response.context) + self.assertIn("seo_title", response.context) + self.assertIn("seo_description", response.context) + self.assertIn("seo_canonical_url", response.context) + self.assertIn("seo_og_title", response.context) + self.assertIn("seo_og_description", response.context) + self.assertIn("seo_og_type", response.context) + self.assertIn("seo_og_url", response.context) + self.assertIn("seo_og_image", response.context) partners_qs = response.context["partners_qs"] self.assertEqual( @@ -100,6 +108,9 @@ def _assert_common_data(self, response): self.assertIn("Enabled B", partners_json) self.assertNotIn("Disabled", partners_json) + self.assertTrue(response.context["seo_title"]) + self.assertTrue(response.context["seo_description"]) + def test_static_showcase_views_status_template_and_common_data(self): routes = [ ("/", "home/main.html"), @@ -138,3 +149,37 @@ def test_dynamic_showcase_views_status_template_and_common_data(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, template_name) self._assert_common_data(response) + + def test_dynamic_showcase_views_use_specific_seo_metadata(self): + cases = [ + ( + "/events/integration/", + "BDE UTT | Week-end Integration", + "Programme, infos pratiques et conseils pour le week-end d'integration organise par le BDE UTT.", + ), + ( + "/events/r2d/", + "BDE UTT | R2D", + "Decouvrez la R2D, un evenement phare du BDE UTT et de la vie etudiante.", + ), + ( + "/services/tickets/", + "BDE UTT | Plateforme Tickets", + "Utilisez la plateforme tickets de l'UNG pour demander un support rapide.", + ), + ( + "/services/clubs/", + "BDE UTT | Clubs et Assos", + "Retrouvez les informations sur la gestion des clubs et associations de l'UTT ainsi que les contacts utiles proposes par le BDE.", + ), + ] + + for url, expected_title, expected_description in cases: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["seo_title"], expected_title) + self.assertEqual( + response.context["seo_description"], + expected_description, + ) diff --git a/showcase/views.py b/showcase/views.py index 73b2550..a6cbc1e 100644 --- a/showcase/views.py +++ b/showcase/views.py @@ -4,11 +4,88 @@ from .models import UsefulContact, BDEEmail, BDEPhone +EVENTS_SEO_BY_PARAM: dict[str, dict[str, str]] = { + "integration": { + "title": "BDE UTT | Week-end Integration", + "description": "Programme, infos pratiques et conseils pour le week-end d'integration organise par le BDE UTT.", + }, + "r2d": { + "title": "BDE UTT | R2D", + "description": "Decouvrez la R2D, un evenement phare du BDE UTT et de la vie etudiante.", + }, + "sdf": { + "title": "BDE UTT | SDF", + "description": "Informations sur la SDF, un evenement organise par le BDE UTT.", + }, +} + + +SERVICES_SEO_BY_PARAM: dict[str, dict[str, str]] = { + "campus": { + "title": "BDE UTT | Campus", + "description": "La vie de Campus du l'UTT pour simplifier votre quotidien etudiant.", + }, + "clubs": { + "title": "BDE UTT | Clubs et Assos", + "description": "Retrouvez les informations sur la gestion des clubs et associations de l'UTT ainsi que les contacts utiles proposes par le BDE.", + }, + "communication": { + "title": "BDE UTT | Communication", + "description": "Les actions de communication du BDE UTT pour informer les etudiants.", + }, + "foyer": { + "title": "BDE UTT | Foyer", + "description": "Decouvrez le foyer de l'UTT et les services proposes par le BDE.", + }, + "loan": { + "title": "BDE UTT | Pret de materiel", + "description": "Consultez les modalites de pret de materiel proposees par le BDE UTT.", + }, + "tickets": { + "title": "BDE UTT | Plateforme Tickets", + "description": "Utilisez la plateforme tickets de l'UNG pour demander un support rapide.", + }, + "treasury": { + "title": "BDE UTT | Tresorerie", + "description": "Informations de tresorerie et accompagnement financier proposes par le BDE UTT.", + }, + "zeshop": { + "title": "BDE UTT | ZeShop", + "description": "Decouvrez ZeShop, la boutique du BDE UTT et ses produits pour les etudiants.", + }, +} + + +def _seo_for_param( + mapping: dict[str, dict[str, str]], + param: str, + title_prefix: str, + description_prefix: str, +) -> dict[str, str]: + seo = mapping.get(param) + if seo is not None: + return seo + + readable_param = param.replace("-", " ").title() + return { + "title": f"BDE UTT | {title_prefix} {readable_param}", + "description": f"{description_prefix} {readable_param} proposé par le BDE UTT.", + } + + def home(request): return render( request, "home/main.html", - {**common_data()}, + { + **common_data( + request, + seo={ + "title": "BDE UTT | Accueil", + "description": "Bienvenue sur le site du BDE UTT : vie etudiante, evenements, services et actualites associatives.", + }, + ) + }, ) @@ -17,7 +94,13 @@ def contacts(request): request, "contacts/main.html", { - **common_data(), + **common_data( + request, + seo={ + "title": "BDE UTT | Contacts", + "description": "Retrouvez les adresses email et les numeros utiles du Bureau des Etudiants de l'UTT.", + }, + ), "bde_emails": BDEEmail.objects.all(), "bde_phones": BDEPhone.objects.all(), }, @@ -48,14 +131,32 @@ def _get_google_calendar_settings() -> dict: request, "events/main.html", { - **common_data(), + **common_data( + request, + seo={ + "title": "BDE UTT | Evenements", + "description": "Decouvrez les evenements du BDE UTT et consultez le calendrier associatif.", + }, + ), **_get_google_calendar_settings(), }, ) + + param_seo = _seo_for_param( + EVENTS_SEO_BY_PARAM, + param, + "Evenement", + "Informations sur l'evenement", + ) return render( request, f"events/{param}/main.html", - {**common_data()}, + { + **common_data( + request, + seo=param_seo, + ) + }, ) @@ -63,7 +164,15 @@ def membership(request): return render( request, "membership/main.html", - {**common_data()}, + { + **common_data( + request, + seo={ + "title": "BDE UTT | Adhesion", + "description": "Toutes les informations pour adherer au BDE UTT et profiter des services associes.", + }, + ) + }, ) @@ -71,7 +180,15 @@ def partners(request): return render( request, "partners/main.html", - {**common_data()}, + { + **common_data( + request, + seo={ + "title": "BDE UTT | Partenaires", + "description": "Decouvrez les partenaires du BDE UTT et les avantages proposes aux etudiants.", + }, + ) + }, ) @@ -89,13 +206,31 @@ def _get_userful_contacts(param: str) -> dict: return render( request, "services/main.html", - {**common_data()}, + { + **common_data( + request, + seo={ + "title": "BDE UTT | Services", + "description": "Explorez les services du BDE UTT pour faciliter votre vie etudiante.", + }, + ) + }, ) + + param_seo = _seo_for_param( + SERVICES_SEO_BY_PARAM, + param, + "Service", + "Details du service", + ) return render( request, f"services/{param}/main.html", { - **common_data(), + **common_data( + request, + seo=param_seo, + ), **_get_userful_contacts(param), }, ) diff --git a/static/css/main.css b/static/css/main.css index dbb1535..77ecea2 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -51,6 +51,15 @@ nav { text-align: justify; } +.role-suffix { + display: inline-block; + width: 1ch; +} + +.role-suffix--hidden { + visibility: hidden; +} + .width-limited-sm { min-width: 300px; max-width: 500px; diff --git a/templates/admin/login.html b/templates/admin/login.html index 882d80a..e22fa12 100644 --- a/templates/admin/login.html +++ b/templates/admin/login.html @@ -6,8 +6,6 @@ {% endblock %} - - {% block content %}
diff --git a/templates/base.html b/templates/base.html index e604340..262071e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,7 +5,32 @@ - {% block title %}BDE UTT{% endblock %} + {% if seo_title %}{{ seo_title }}{% else %}{% block title %}BDE UTT{% endblock %}{% endif %} + + + {% if seo_canonical_url %} + + {% endif %} + + + + + + + {% if seo_og_url %} + + {% endif %} + {% if seo_og_image %} + + {% endif %} + + + + + {% if seo_og_image %} + + {% endif %} + {% block head %}{% endblock %} diff --git a/templates/partials/cards/step.html b/templates/partials/cards/step.html index 4712833..9056039 100644 --- a/templates/partials/cards/step.html +++ b/templates/partials/cards/step.html @@ -1,6 +1,6 @@ {% extends "partials/cards/base.html" %} -{% block card_classes %}modern-card h-100 border-0 shadow-sm text-center{% endblock %} +{% block card_classes %}modern-card h-100 border-0 shadow text-center{% endblock %} {% block card_body_classes %}d-flex flex-column p-4{% endblock %} {% block card_title %} diff --git a/templates/partials/footer.html b/templates/partials/footer.html index 10b43c7..c8f39c5 100644 --- a/templates/partials/footer.html +++ b/templates/partials/footer.html @@ -11,7 +11,6 @@
Le Bureau des Étudiants de l'Université de Technologie de Troyes.

-
diff --git a/utils/views.py b/utils/views.py index cb065d2..961195b 100644 --- a/utils/views.py +++ b/utils/views.py @@ -1,9 +1,47 @@ from showcase.models import Partner from datetime import datetime import json +from typing import Any +from django.http import HttpRequest +from bde.settings import DEFAULT_SEO_TITLE, DEFAULT_SEO_DESCRIPTION, DEFAULT_SEO_IMAGE -def common_data(): +def _absolute_url(request: HttpRequest | None, path: str) -> str: + if request is None: + return path + return request.build_absolute_uri(path) + + +def build_seo_data( + request: HttpRequest | None = None, + seo: dict[str, Any] | None = None, +) -> dict[str, str]: + seo = seo or {} + + title = str(seo.get("title") or DEFAULT_SEO_TITLE) + description = str(seo.get("description") or DEFAULT_SEO_DESCRIPTION) + canonical_url = str(seo.get("canonical_url") or _absolute_url(request, "")) + og_title = str(seo.get("og_title") or title) + og_description = str(seo.get("og_description") or description) + og_type = str(seo.get("og_type") or "website") + og_image = str(seo.get("og_image") or _absolute_url(request, DEFAULT_SEO_IMAGE)) + + return { + "seo_title": title, + "seo_description": description, + "seo_canonical_url": canonical_url, + "seo_og_title": og_title, + "seo_og_description": og_description, + "seo_og_type": og_type, + "seo_og_url": canonical_url, + "seo_og_image": og_image, + } + + +def common_data( + request: HttpRequest | None = None, + seo: dict[str, Any] | None = None, +): partners_qs = Partner.objects.filter(enable=True).order_by("order") partners_list = [ @@ -20,4 +58,5 @@ def common_data(): "partners_qs": partners_qs, "partners_json": json.dumps(partners_list), "current_year": datetime.now().year, + **build_seo_data(request, seo), }