diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..5c4c0d97 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +HTTPS=off +SITH_DEBUG=true + +# This is not the real key used in prod +SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2 + +# comment the sqlite line and uncomment the postgres one to switch the dbms +DATABASE_URL=sqlite:///db.sqlite3 +#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith + +CACHE_URL=redis://127.0.0.1:6379/0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57f36d6f..aa17e14c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ on: branches: [master, taiste] workflow_dispatch: +env: + SECRET_KEY: notTheRealOne + DATABASE_URL: sqlite:///db.sqlite3 + jobs: pre-commit: name: Launch pre-commits checks (ruff) diff --git a/.gitignore b/.gitignore index cf8d7f67..19b65265 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ node_modules/ # compiled documentation site/ +.env diff --git a/club/schemas.py b/club/schemas.py index cbd35988..7969f119 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -7,3 +7,17 @@ class ClubSchema(ModelSchema): class Meta: model = Club fields = ["id", "name"] + + +class ClubProfileSchema(ModelSchema): + """The infos needed to display a simple club profile.""" + + class Meta: + model = Club + fields = ["id", "name", "logo"] + + url: str + + @staticmethod + def resolve_url(obj: Club) -> str: + return obj.get_absolute_url() diff --git a/com/api.py b/com/api.py index e46daea9..99186f36 100644 --- a/com/api.py +++ b/com/api.py @@ -1,10 +1,18 @@ from pathlib import Path +from typing import Literal from django.conf import settings -from django.http import Http404 -from ninja_extra import ControllerBase, api_controller, route +from django.http import Http404, HttpResponse +from ninja import Query +from ninja_extra import ControllerBase, api_controller, paginate, route +from ninja_extra.pagination import PageNumberPaginationExtra +from ninja_extra.permissions import IsAuthenticated +from ninja_extra.schemas import PaginatedResponseSchema from com.calendar import IcsCalendar +from com.models import News, NewsDate +from com.schemas import NewsDateFilterSchema, NewsDateSchema +from core.auth.api_permissions import HasPerm from core.views.files import send_raw_file @@ -17,7 +25,7 @@ class CalendarController(ControllerBase): """Return the ICS file of the AE Google Calendar Because of Google's cors rules, we can't just do a request to google ics - from the frontend. Google is blocking CORS request in it's responses headers. + from the frontend. Google is blocking CORS request in its responses headers. The only way to do it from the frontend is to use Google Calendar API with an API key This is not especially desirable as your API key is going to be provided to the frontend. @@ -30,3 +38,67 @@ class CalendarController(ControllerBase): @route.get("/internal.ics", url_name="calendar_internal") def calendar_internal(self): return send_raw_file(IcsCalendar.get_internal()) + + @route.get( + "/unpublished.ics", + permissions=[IsAuthenticated], + url_name="calendar_unpublished", + ) + def calendar_unpublished(self): + return HttpResponse( + IcsCalendar.get_unpublished(self.context.request.user), + content_type="text/calendar", + ) + + +@api_controller("/news") +class NewsController(ControllerBase): + @route.patch( + "/{int:news_id}/publish", + permissions=[HasPerm("com.moderate_news")], + url_name="moderate_news", + ) + def publish_news(self, news_id: int): + news = self.get_object_or_exception(News, id=news_id) + if not news.is_published: + news.is_published = True + news.moderator = self.context.request.user + news.save() + + @route.patch( + "/{int:news_id}/unpublish", + permissions=[HasPerm("com.moderate_news")], + url_name="unpublish_news", + ) + def unpublish_news(self, news_id: int): + news = self.get_object_or_exception(News, id=news_id) + if news.is_published: + news.is_published = False + news.moderator = self.context.request.user + news.save() + + @route.delete( + "/{int:news_id}", + permissions=[HasPerm("com.delete_news")], + url_name="delete_news", + ) + def delete_news(self, news_id: int): + news = self.get_object_or_exception(News, id=news_id) + news.delete() + + @route.get( + "/date", + url_name="fetch_news_dates", + response=PaginatedResponseSchema[NewsDateSchema], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def fetch_news_dates( + self, + filters: Query[NewsDateFilterSchema], + text_format: Literal["md", "html"] = "md", + ): + return filters.filter( + NewsDate.objects.viewable_by(self.context.request.user) + .order_by("start_date") + .select_related("news", "news__club") + ) diff --git a/com/calendar.py b/com/calendar.py index 9003d6de..1c95a2b3 100644 --- a/com/calendar.py +++ b/com/calendar.py @@ -2,9 +2,10 @@ from datetime import datetime, timedelta from pathlib import Path from typing import final -import urllib3 +import requests from dateutil.relativedelta import relativedelta from django.conf import settings +from django.db.models import F, QuerySet from django.urls import reverse from django.utils import timezone from ical.calendar import Calendar @@ -12,6 +13,7 @@ from ical.calendar_stream import IcsCalendarStream from ical.event import Event from com.models import NewsDate +from core.models import User @final @@ -35,16 +37,15 @@ class IcsCalendar: @classmethod def make_external(cls) -> Path | None: - calendar = urllib3.request( - "GET", - "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", + calendar = requests.get( + "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics" ) - if calendar.status != 200: + if not calendar.ok: return None cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) with open(cls._EXTERNAL_CALENDAR, "wb") as f: - _ = f.write(calendar.data) + _ = f.write(calendar.content) return cls._EXTERNAL_CALENDAR @classmethod @@ -56,21 +57,38 @@ class IcsCalendar: @classmethod def make_internal(cls) -> Path: # Updated through a post_save signal on News in com.signals + # Create a file so we can offload the download to the reverse proxy if available + cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) + with open(cls._INTERNAL_CALENDAR, "wb") as f: + _ = f.write( + cls.ics_from_queryset( + NewsDate.objects.filter( + news__is_published=True, + end_date__gte=timezone.now() - (relativedelta(months=6)), + ) + ) + ) + return cls._INTERNAL_CALENDAR + + @classmethod + def get_unpublished(cls, user: User) -> bytes: + return cls.ics_from_queryset( + NewsDate.objects.viewable_by(user).filter( + news__is_published=False, + end_date__gte=timezone.now() - (relativedelta(months=6)), + ), + ) + + @classmethod + def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes: calendar = Calendar() - for news_date in NewsDate.objects.filter( - news__is_moderated=True, - end_date__gte=timezone.now() - (relativedelta(months=6)), - ).prefetch_related("news"): + for news_date in queryset.annotate(news_title=F("news__title")): event = Event( - summary=news_date.news.title, + summary=news_date.news_title, start=news_date.start_date, end=news_date.end_date, url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), ) calendar.events.append(event) - # Create a file so we can offload the download to the reverse proxy if available - cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) - with open(cls._INTERNAL_CALENDAR, "wb") as f: - _ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")) - return cls._INTERNAL_CALENDAR + return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8") diff --git a/com/forms.py b/com/forms.py index 471e6632..8b81a3f9 100644 --- a/com/forms.py +++ b/com/forms.py @@ -147,8 +147,8 @@ class NewsForm(forms.ModelForm): "content": MarkdownInput, } - auto_moderate = forms.BooleanField( - label=_("Automoderation"), + auto_publish = forms.BooleanField( + label=_("Auto publication"), widget=CheckboxInput(attrs={"class": "switch"}), required=False, ) @@ -182,12 +182,12 @@ class NewsForm(forms.ModelForm): def save(self, commit: bool = True): # noqa FBT001 self.instance.author = self.author if (self.author.is_com_admin or self.author.is_root) and ( - self.cleaned_data.get("auto_moderate") is True + self.cleaned_data.get("auto_publish") is True ): - self.instance.is_moderated = True + self.instance.is_published = True self.instance.moderator = self.author else: - self.instance.is_moderated = False + self.instance.is_published = False created_news = super().save(commit=commit) self.date_form.save(commit=commit, news=created_news) return created_news diff --git a/com/migrations/0009_remove_news_is_moderated_news_is_published.py b/com/migrations/0009_remove_news_is_moderated_news_is_published.py new file mode 100644 index 00000000..dd633820 --- /dev/null +++ b/com/migrations/0009_remove_news_is_moderated_news_is_published.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("com", "0008_alter_news_options_alter_newsdate_options_and_more")] + + operations = [ + migrations.RenameField( + model_name="news", old_name="is_moderated", new_name="is_published" + ), + migrations.AlterField( + model_name="news", + name="is_published", + field=models.BooleanField(default=False, verbose_name="is published"), + ), + ] diff --git a/com/models.py b/com/models.py index 1219410a..2b3a76c4 100644 --- a/com/models.py +++ b/com/models.py @@ -56,7 +56,7 @@ class Sith(models.Model): class NewsQuerySet(models.QuerySet): def moderated(self) -> Self: - return self.filter(is_moderated=True) + return self.filter(is_published=True) def viewable_by(self, user: User) -> Self: """Filter news that the given user can view. @@ -68,7 +68,7 @@ class NewsQuerySet(models.QuerySet): """ if user.has_perm("com.view_unmoderated_news"): return self - q_filter = Q(is_moderated=True) + q_filter = Q(is_published=True) if user.is_authenticated: q_filter |= Q(author_id=user.id) return self.filter(q_filter) @@ -104,7 +104,7 @@ class News(models.Model): verbose_name=_("author"), on_delete=models.PROTECT, ) - is_moderated = models.BooleanField(_("is moderated"), default=False) + is_published = models.BooleanField(_("is published"), default=False) moderator = models.ForeignKey( User, related_name="moderated_news", @@ -127,7 +127,7 @@ class News(models.Model): def save(self, *args, **kwargs): super().save(*args, **kwargs) - if self.is_moderated: + if self.is_published: return for user in User.objects.filter( groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID] @@ -154,7 +154,7 @@ class News(models.Model): def can_be_viewed_by(self, user: User): return ( - self.is_moderated + self.is_published or user.has_perm("com.view_unmoderated_news") or (user.is_authenticated and self.author_id == user.id) ) @@ -162,7 +162,7 @@ class News(models.Model): def news_notification_callback(notif): count = News.objects.filter( - dates__start_date__gt=timezone.now(), is_moderated=False + dates__start_date__gt=timezone.now(), is_published=False ).count() if count: notif.viewed = False @@ -172,6 +172,22 @@ def news_notification_callback(notif): notif.viewed = True +class NewsDateQuerySet(models.QuerySet): + def viewable_by(self, user: User) -> Self: + """Filter the event dates that the given user can view. + + - If the can view non moderated news, he can view all news dates + - else, he can view the dates of news that are either + authored by him or moderated. + """ + if user.has_perm("com.view_unmoderated_news"): + return self + q_filter = Q(news__is_published=True) + if user.is_authenticated: + q_filter |= Q(news__author_id=user.id) + return self.filter(q_filter) + + class NewsDate(models.Model): """A date associated with news. @@ -187,6 +203,8 @@ class NewsDate(models.Model): start_date = models.DateTimeField(_("start_date")) end_date = models.DateTimeField(_("end_date")) + objects = NewsDateQuerySet.as_manager() + class Meta: verbose_name = _("news date") verbose_name_plural = _("news dates") @@ -319,7 +337,7 @@ class Screen(models.Model): def active_posters(self): now = timezone.now() - return self.posters.filter(is_moderated=True, date_begin__lte=now).filter( + return self.posters.filter(d=True, date_begin__lte=now).filter( Q(date_end__isnull=True) | Q(date_end__gte=now) ) diff --git a/com/schemas.py b/com/schemas.py new file mode 100644 index 00000000..93ee5315 --- /dev/null +++ b/com/schemas.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from ninja import FilterSchema, ModelSchema +from ninja_extra import service_resolver +from ninja_extra.controllers import RouteContext +from pydantic import Field + +from club.schemas import ClubProfileSchema +from com.models import News, NewsDate +from core.markdown import markdown + + +class NewsDateFilterSchema(FilterSchema): + before: datetime | None = Field(None, q="end_date__lt") + after: datetime | None = Field(None, q="start_date__gt") + club_id: int | None = Field(None, q="news__club_id") + news_id: int | None = None + is_published: bool | None = Field(None, q="news__is_published") + title: str | None = Field(None, q="news__title__icontains") + + +class NewsSchema(ModelSchema): + class Meta: + model = News + fields = ["id", "title", "summary", "is_published"] + + club: ClubProfileSchema + url: str + + @staticmethod + def resolve_summary(obj: News) -> str: + # if this is returned from a route that allows the + # user to choose the text format (md or html) + # and the user chose "html", convert the markdown to html + context: RouteContext = service_resolver(RouteContext) + if context.kwargs.get("text_format", "") == "html": + return markdown(obj.summary) + return obj.summary + + @staticmethod + def resolve_url(obj: News) -> str: + return obj.get_absolute_url() + + +class NewsDateSchema(ModelSchema): + """Basic infos about an event occurrence. + + Warning: + This uses [NewsSchema][], which itself + uses [ClubProfileSchema][club.schemas.ClubProfileSchema]. + Don't forget the appropriated `select_related`. + """ + + class Meta: + model = NewsDate + fields = ["id", "start_date", "end_date"] + + news: NewsSchema diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 3c78f98f..0b4976b0 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -7,20 +7,33 @@ import frLocale from "@fullcalendar/core/locales/fr"; import dayGridPlugin from "@fullcalendar/daygrid"; import iCalendarPlugin from "@fullcalendar/icalendar"; import listPlugin from "@fullcalendar/list"; -import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi"; +import { + calendarCalendarExternal, + calendarCalendarInternal, + calendarCalendarUnpublished, + newsDeleteNews, + newsPublishNews, + newsUnpublishNews, +} from "#openapi"; @registerComponent("ics-calendar") export class IcsCalendar extends inheritHtmlElement("div") { - static observedAttributes = ["locale"]; + static observedAttributes = ["locale", "can_moderate", "can_delete"]; private calendar: Calendar; private locale = "en"; + private canModerate = false; + private canDelete = false; attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { - if (name !== "locale") { - return; + if (name === "locale") { + this.locale = newValue; + } + if (name === "can_moderate") { + this.canModerate = newValue.toLowerCase() === "true"; + } + if (name === "can_delete") { + this.canDelete = newValue.toLowerCase() === "true"; } - - this.locale = newValue; } isMobile() { @@ -54,6 +67,104 @@ export class IcsCalendar extends inheritHtmlElement("div") { }).format(date); } + getNewsId(event: EventImpl) { + return Number.parseInt( + event.url + .toString() + .split("/") + .filter((s) => s) // Remove blank characters + .pop(), + ); + } + + async refreshEvents() { + this.click(); // Remove focus from popup + // We can't just refresh events because some ics files are in + // local browser cache (especially internal.ics) + // To invalidate the cache, we need to remove the source and add it again + this.calendar.removeAllEventSources(); + for (const source of await this.getEventSources()) { + this.calendar.addEventSource(source); + } + this.calendar.refetchEvents(); + } + + async publishNews(id: number) { + await newsPublishNews({ + path: { + // biome-ignore lint/style/useNamingConvention: python API + news_id: id, + }, + }); + this.dispatchEvent( + new CustomEvent("calendar-publish", { + bubbles: true, + detail: { + id: id, + }, + }), + ); + await this.refreshEvents(); + } + + async unpublishNews(id: number) { + await newsUnpublishNews({ + path: { + // biome-ignore lint/style/useNamingConvention: python API + news_id: id, + }, + }); + this.dispatchEvent( + new CustomEvent("calendar-unpublish", { + bubbles: true, + detail: { + id: id, + }, + }), + ); + await this.refreshEvents(); + } + + async deleteNews(id: number) { + await newsDeleteNews({ + path: { + // biome-ignore lint/style/useNamingConvention: python API + news_id: id, + }, + }); + this.dispatchEvent( + new CustomEvent("calendar-delete", { + bubbles: true, + detail: { + id: id, + }, + }), + ); + await this.refreshEvents(); + } + + async getEventSources() { + const cacheInvalidate = `?invalidate=${Date.now()}`; + return [ + { + url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`, + format: "ics", + className: "internal", + }, + { + url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`, + format: "ics", + className: "external", + }, + { + url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`, + format: "ics", + color: "red", + className: "unpublished", + }, + ]; + } + createEventDetailPopup(event: EventClickArg) { // Delete previous popup const oldPopup = document.getElementById("event-details"); @@ -112,6 +223,47 @@ export class IcsCalendar extends inheritHtmlElement("div") { return makePopupInfo(url, "fa-solid fa-link"); }; + const makePopupTools = (event: EventImpl) => { + if (event.source.internalEventSource.ui.classNames.includes("external")) { + return null; + } + if (!(this.canDelete || this.canModerate)) { + return null; + } + const newsId = this.getNewsId(event); + const div = document.createElement("div"); + if (this.canModerate) { + if (event.source.internalEventSource.ui.classNames.includes("unpublished")) { + const button = document.createElement("button"); + button.innerHTML = `${gettext("Publish")}`; + button.setAttribute("class", "btn btn-green"); + button.onclick = () => { + this.publishNews(newsId); + }; + div.appendChild(button); + } else { + const button = document.createElement("button"); + button.innerHTML = `${gettext("Unpublish")}`; + button.setAttribute("class", "btn btn-orange"); + button.onclick = () => { + this.unpublishNews(newsId); + }; + div.appendChild(button); + } + } + if (this.canDelete) { + const button = document.createElement("button"); + button.innerHTML = `${gettext("Delete")}`; + button.setAttribute("class", "btn btn-red"); + button.onclick = () => { + this.deleteNews(newsId); + }; + div.appendChild(button); + } + + return makePopupInfo(div, "fa-solid fa-toolbox"); + }; + // Create new popup const popup = document.createElement("div"); const popupContainer = document.createElement("div"); @@ -131,6 +283,11 @@ export class IcsCalendar extends inheritHtmlElement("div") { popupContainer.appendChild(url); } + const tools = makePopupTools(event.event); + if (tools !== null) { + popupContainer.appendChild(tools); + } + popup.appendChild(popupContainer); // We can't just add the element relative to the one we want to appear under @@ -152,7 +309,6 @@ export class IcsCalendar extends inheritHtmlElement("div") { async connectedCallback() { super.connectedCallback(); - const cacheInvalidate = `?invalidate=${Date.now()}`; this.calendar = new Calendar(this.node, { plugins: [dayGridPlugin, iCalendarPlugin, listPlugin], locales: [frLocale, enLocale], @@ -160,16 +316,7 @@ export class IcsCalendar extends inheritHtmlElement("div") { locale: this.locale, initialView: this.currentView(), headerToolbar: this.currentToolbar(), - eventSources: [ - { - url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`, - format: "ics", - }, - { - url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`, - format: "ics", - }, - ], + eventSources: await this.getEventSources(), windowResize: () => { this.calendar.changeView(this.currentView()); this.calendar.setOption("headerToolbar", this.currentToolbar()); diff --git a/com/static/bundled/com/components/moderation-alert-index.ts b/com/static/bundled/com/components/moderation-alert-index.ts new file mode 100644 index 00000000..f2ff1806 --- /dev/null +++ b/com/static/bundled/com/components/moderation-alert-index.ts @@ -0,0 +1,81 @@ +import { exportToHtml } from "#core:utils/globals"; +import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi"; + +// This will be used in jinja templates, +// so we cannot use real enums as those are purely an abstraction of Typescript +const AlertState = { + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + PENDING: 1, + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + PUBLISHED: 2, + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + DELETED: 3, + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + DISPLAYED: 4, // When published at page generation +}; +exportToHtml("AlertState", AlertState); + +document.addEventListener("alpine:init", () => { + Alpine.data("moderationAlert", (newsId: number) => ({ + state: AlertState.PENDING, + newsId: newsId as number, + loading: false, + + async publishNews() { + this.loading = true; + // biome-ignore lint/style/useNamingConvention: api is snake case + await newsPublishNews({ path: { news_id: this.newsId } }); + this.state = AlertState.PUBLISHED; + this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state }); + this.loading = false; + }, + + async deleteNews() { + this.loading = true; + // biome-ignore lint/style/useNamingConvention: api is snake case + await newsDeleteNews({ path: { news_id: this.newsId } }); + this.state = AlertState.DELETED; + this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state }); + this.loading = false; + }, + + /** + * Event receiver for when news dates are moderated. + * + * If the moderated date is linked to the same news + * as the one this moderation alert is attached to, + * then set the alert state to the same as the moderated one. + */ + dispatchModeration(event: CustomEvent) { + if (event.detail.newsId === this.newsId) { + this.state = event.detail.state; + } + }, + + /** + * Query the server to know the number of news dates that would be moderated + * if this one is moderated. + */ + async nbToPublish(): Promise { + // What we want here is the count attribute of the response. + // We don't care about the actual results, + // so we ask for the minimum page size possible. + const response = await newsFetchNewsDates({ + // biome-ignore lint/style/useNamingConvention: api is snake-case + query: { news_id: this.newsId, page: 1, page_size: 1 }, + }); + return response.data.count; + }, + + weeklyEventWarningMessage(nbEvents: number): string { + return interpolate( + gettext( + "This event will take place every week for %s weeks. " + + "If you publish or delete this event, " + + "it will also be published (or deleted) for the following weeks.", + ), + [nbEvents], + ); + }, + })); +}); diff --git a/com/static/bundled/com/components/upcoming-news-loader-index.ts b/com/static/bundled/com/components/upcoming-news-loader-index.ts new file mode 100644 index 00000000..ccc1e714 --- /dev/null +++ b/com/static/bundled/com/components/upcoming-news-loader-index.ts @@ -0,0 +1,67 @@ +import { type NewsDateSchema, newsFetchNewsDates } from "#openapi"; + +interface ParsedNewsDateSchema extends Omit { + // biome-ignore lint/style/useNamingConvention: api is snake_case + start_date: Date; + // biome-ignore lint/style/useNamingConvention: api is snake_case + end_date: Date; +} + +document.addEventListener("alpine:init", () => { + Alpine.data("upcomingNewsLoader", (startDate: Date) => ({ + startDate: startDate, + currentPage: 1, + pageSize: 6, + hasNext: true, + loading: false, + newsDates: [] as NewsDateSchema[], + + async loadMore() { + this.loading = true; + const response = await newsFetchNewsDates({ + query: { + after: this.startDate.toISOString(), + // biome-ignore lint/style/useNamingConvention: api is snake_case + text_format: "html", + page: this.currentPage, + // biome-ignore lint/style/useNamingConvention: api is snake_case + page_size: this.pageSize, + }, + }); + if (response.response.status === 404) { + this.hasNext = false; + } else if (response.data.next === null) { + this.newsDates.push(...response.data.results); + this.hasNext = false; + } else { + this.newsDates.push(...response.data.results); + this.currentPage += 1; + } + this.loading = false; + }, + + groupedDates(): Record { + return this.newsDates + .map( + (date: NewsDateSchema): ParsedNewsDateSchema => ({ + ...date, + // biome-ignore lint/style/useNamingConvention: api is snake_case + start_date: new Date(date.start_date), + // biome-ignore lint/style/useNamingConvention: api is snake_case + end_date: new Date(date.end_date), + }), + ) + .reduce( + (acc: Record, date: ParsedNewsDateSchema) => { + const key = date.start_date.toDateString(); + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(date); + return acc; + }, + {}, + ); + }, + })); +}); diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index d073c4ac..40da2157 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -51,6 +51,20 @@ } } + /* UPCOMING EVENTS */ + + #upcoming-events { + max-height: 600px; + overflow-y: scroll; + + #load-more-news-button { + text-align: center; + button { + width: 150px; + } + } + } + /* LINKS/BIRTHDAYS */ #links, #birthdays { @@ -171,54 +185,24 @@ } .news_event { - display: block; - padding: 0.4em; - - &:not(:last-child) { - border-bottom: 1px solid grey; - } - - div { - margin: 0.2em; - } - - h4 { - margin-top: 1em; - text-transform: uppercase; - } - - .club_logo { - float: left; - min-width: 7em; - max-width: 9em; - margin: 0; - margin-right: 1em; - margin-top: 0.8em; + display: flex; + flex-direction: column; + gap: .5em; + padding: 1em; + header { img { - max-height: 6em; - max-width: 8em; - display: block; - margin: 0 auto; + height: 75px; } - } + .header_content { + display: flex; + flex-direction: column; + justify-content: center; + gap: .2rem; - .news_date { - font-size: 100%; - } - - .news_content { - clear: left; - - .button_bar { - text-align: right; - - .fb { - color: $faceblue; - } - - .twitter { - color: $twitblue; + h4 { + margin-top: 0; + text-transform: uppercase; } } } @@ -228,70 +212,6 @@ /* END EVENTS TODAY AND NEXT FEW DAYS */ - /* COMING SOON */ - .news_coming_soon { - display: list-item; - list-style-type: square; - list-style-position: inside; - margin-left: 1em; - padding-left: 0; - - a { - font-weight: bold; - text-transform: uppercase; - } - - .news_date { - font-size: 0.9em; - } - } - - /* END COMING SOON */ - - /* NOTICES */ - .news_notice { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - box-shadow: $shadow-color 0 0 2px; - border-radius: 18px 5px 18px 5px; - - h4 { - margin: 0; - } - - .news_content { - margin-left: 1em; - } - } - - /* END NOTICES */ - - /* CALLS */ - .news_call { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - border: 1px solid grey; - box-shadow: $shadow-color 1px 1px 1px; - - h4 { - margin: 0; - } - - .news_date { - font-size: 0.9em; - } - - .news_content { - margin-left: 1em; - } - } - - /* END CALLS */ - .news_empty { margin-left: 1em; } diff --git a/com/templates/com/macros.jinja b/com/templates/com/macros.jinja new file mode 100644 index 00000000..1e3fb853 --- /dev/null +++ b/com/templates/com/macros.jinja @@ -0,0 +1,127 @@ +{% macro news_moderation_alert(news, user, alpineState = None) %} + {# An alert to display on top of unpublished news, + with actions to either publish or delete them. + + The current state of the alert is accessible through + the given `alpineState` variable. + This state is a `AlertState`, as defined in `moderation-alert-index.ts` + + This comes in three flavours : + - You can pass the `News` object itself to the macro. + In this case, if `request.user` can publish news, + it will perform an additional db query to know if it is a recurring event. + - You can also give only the news id. + In this case, a server request will be issued to know + if it is a recurring event. + - Finally, you can pass the name of an alpine variable, which value is the id. + In this case, a server request will be issued to know + if it is a recurring event. + + Example with full `News` object : + ```jinja +
+ {{ news_moderation_alert(news, user, "state") }} +
+ ``` + With an id : + ```jinja +
+ {{ news_moderation_alert(news.id, user, "state") }} +
+ ``` + An with an alpine variable + ```jinja +
+ {{ news_moderation_alert("newsId", user, "state") }} +
+ ``` + + + Args: + news: (News | int | string) + Either the `News` object to which this alert is related, + or its id, or the name of an Alpine which value is its id + user: The request.user + alpineState: An alpine variable name + + Warning: + If you use this macro, you must also include `moderation-alert-index.ts` + in your template. + #} +
+ + + +
+{% endmacro %} diff --git a/com/templates/com/news_admin_list.jinja b/com/templates/com/news_admin_list.jinja index 3884cfc7..2ddbb69d 100644 --- a/com/templates/com/news_admin_list.jinja +++ b/com/templates/com/news_admin_list.jinja @@ -27,7 +27,7 @@ - {% for news in weeklies.filter(is_moderated=True) %} + {% for news in weeklies.filter(is_published=True) %} {{ news.title }} {{ news.summary|markdown }} @@ -47,7 +47,7 @@ {% trans %}View{% endtrans %} {% trans %}Edit{% endtrans %} - {% trans %}Remove{% endtrans %} + {% trans %}Unpublish{% endtrans %} {% trans %}Delete{% endtrans %} @@ -67,7 +67,7 @@ - {% for news in weeklies.filter(is_moderated=False) %} + {% for news in weeklies.filter(is_published=False) %} {{ news.title }} {{ news.summary|markdown }} @@ -86,7 +86,7 @@ {% trans %}View{% endtrans %} {% trans %}Edit{% endtrans %} - {% trans %}Moderate{% endtrans %} + {% trans %}Publish{% endtrans %} {% trans %}Delete{% endtrans %} @@ -111,7 +111,7 @@ - {% for news in events.filter(is_moderated=True) %} + {% for news in events.filter(is_published=True) %} {{ news.title }} {{ news.summary|markdown }} @@ -124,7 +124,7 @@ {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }} {% trans %}View{% endtrans %} {% trans %}Edit{% endtrans %} - {% trans %}Remove{% endtrans %} + {% trans %}Unpublish{% endtrans %} {% trans %}Delete{% endtrans %} @@ -145,7 +145,7 @@ - {% for news in events.filter(is_moderated=False) %} + {% for news in events.filter(is_published=False) %} {{ news.title }} {{ news.summary|markdown }} @@ -157,7 +157,7 @@ {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }} {% trans %}View{% endtrans %} {% trans %}Edit{% endtrans %} - {% trans %}Moderate{% endtrans %} + {% trans %}Publish{% endtrans %} {% trans %}Delete{% endtrans %} diff --git a/com/templates/com/news_detail.jinja b/com/templates/com/news_detail.jinja index 454eb2ef..fc45d24e 100644 --- a/com/templates/com/news_detail.jinja +++ b/com/templates/com/news_detail.jinja @@ -1,5 +1,6 @@ {% extends "core/base.jinja" %} {% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %} +{% from "com/macros.jinja" import news_moderation_alert %} {% block title %} {% trans %}News{% endtrans %} - @@ -16,39 +17,49 @@ {% endblock %} +{% block additional_js %} + +{% endblock %} + {% block content %}

{% trans %}Back to news{% endtrans %}

-
- -

{{ news.title }}

-

- {{ date.start_date|localtime|date(DATETIME_FORMAT) }} - {{ date.start_date|localtime|time(DATETIME_FORMAT) }} - - {{ date.end_date|localtime|date(DATETIME_FORMAT) }} - {{ date.end_date|localtime|time(DATETIME_FORMAT) }} -

-
-
{{ news.summary|markdown }}
-
-
{{ news.content|markdown }}
- {{ facebook_share(news) }} - {{ tweet(news) }} -
-

{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}

- {% if news.moderator %} -

{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}

- {% elif user.is_com_admin %} -

{% trans %}Moderate{% endtrans %}

- {% endif %} - {% if user.can_edit(news) %} -

{% trans %}Edit (will be moderated again){% endtrans %}

- {% endif %} +
+ + {% if not news.is_published %} + {{ news_moderation_alert(news, user, "newsState") }} + {% endif %} +
-
+

{{ news.title }}

+

+ - + +

+
+
{{ news.summary|markdown }}
+
+
{{ news.content|markdown }}
+ {{ facebook_share(news) }} + {{ tweet(news) }} +
+

{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}

+ {% if news.moderator %} +

{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}

+ {% elif user.is_com_admin %} +

{% trans %}Publish{% endtrans %}

+ {% endif %} + {% if user.can_edit(news) %} +

{% trans %}Edit (will be moderated again){% endtrans %}

+ {% endif %} +
+
+ + {% endblock %} diff --git a/com/templates/com/news_edit.jinja b/com/templates/com/news_edit.jinja index e3302fb5..1e240cde 100644 --- a/com/templates/com/news_edit.jinja +++ b/com/templates/com/news_edit.jinja @@ -80,9 +80,9 @@ {% if user.is_root or user.is_com_admin %}
- {{ form.auto_moderate.errors }} - {{ form.auto_moderate }} - {{ form.auto_moderate.label_tag() }} + {{ form.auto_publish.errors }} + {{ form.auto_publish }} + {{ form.auto_publish.label_tag() }}
{% endif %}

diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 0f1f4301..172cc888 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -1,5 +1,5 @@ {% extends "core/base.jinja" %} -{% from 'core/macros.jinja' import tweet_quick, fb_quick %} +{% from "com/macros.jinja" import news_moderation_alert %} {% block title %} {% trans %}News{% endtrans %} @@ -15,13 +15,13 @@ {% block additional_js %} + + {% endblock %} {% block content %} -
- {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__is_moderated=True).datetimes('start_date', 'day') %}

{% trans %}Events today and the next few days{% endtrans %} @@ -33,57 +33,169 @@ {% endif %} {% if user.is_com_admin %} - {% trans %}Administrate news{% endtrans %} + + {% trans %}Administrate news{% endtrans %} +
- {% endif %} - {% if events_dates %} - {% for d in events_dates %} -
-
-
-
{{ d|localtime|date('D') }}
-
{{ d|localtime|date('d') }}
-
{{ d|localtime|date('b') }}
+ {% endif %} +
+ {% if not news_dates %} +
+ {% trans %}Nothing to come...{% endtrans %} +
+ {% else %} + {% for day, dates_group in news_dates.items() %} +
+
+
+
{{ day|date('D') }}
+
{{ day|date('d') }}
+
{{ day|date('b') }}
+
+
+
+ {% for date in dates_group %} +
+ {# if a non published news is in the object list, + the logged user is either an admin or the news author #} + {{ news_moderation_alert(date.news, user, "newsState") }} +
+
+ {% if date.news.club.logo %} + {{ date.news.club }} + {% else %} + {{ date.news.club }} + {% endif %} +
+

+ + {{ date.news.title }} + +

+ {{ date.news.club }} +
+ - + +
+
+
+
+ {{ date.news.summary|markdown }} +
+
+
+ {% endfor %}
-
- {% for news in object_list.filter(dates__start_date__gte=d,dates__start_date__lte=d+timedelta(days=1)).exclude(dates__end_date__lt=timezone.now()).order_by('dates__start_date') %} -
-

{% trans %}All coming events{% endtrans %}

- +
@@ -130,7 +250,7 @@

{% trans %}Birthdays{% endtrans %}

- {%- if user.was_subscribed -%} + {%- if user.has_perm("core.view_user") -%}
    {%- for year, users in birthdays -%}
  • @@ -143,8 +263,13 @@
  • {%- endfor -%}
- {%- else -%} + {%- elif not user.was_subscribed -%} + {# The user cannot view birthdays, because he never subscribed #}

{% trans %}You need to subscribe to access this content{% endtrans %}

+ {%- else -%} + {# There is another reason why user cannot view birthdays (maybe he is banned) + but we cannot know exactly what is this reason #} +

{% trans %}You cannot access this content{% endtrans %}

{%- endif -%}
diff --git a/com/tests/test_api.py b/com/tests/test_api.py index f131052e..ba48f49c 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -3,24 +3,32 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Callable from unittest.mock import MagicMock, patch +from urllib.parse import quote import pytest from django.conf import settings +from django.contrib.auth.models import Permission from django.http import HttpResponse -from django.test.client import Client +from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone +from django.utils.timezone import now +from model_bakery import baker, seq +from pytest_django.asserts import assertNumQueries from com.calendar import IcsCalendar +from com.models import News, NewsDate +from core.markdown import markdown +from core.models import User @dataclass class MockResponse: - status: int + ok: bool value: str @property - def data(self): + def content(self): return self.value.encode("utf8") @@ -38,7 +46,7 @@ class TestExternalCalendar: @pytest.fixture def mock_request(self): mock = MagicMock() - with patch("urllib3.request", mock): + with patch("requests.get", mock): yield mock @pytest.fixture @@ -52,15 +60,12 @@ class TestExternalCalendar: def clear_cache(self): IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True) - @pytest.mark.parametrize("error_code", [403, 404, 500]) - def test_fetch_error( - self, client: Client, mock_request: MagicMock, error_code: int - ): - mock_request.return_value = MockResponse(error_code, "not allowed") + def test_fetch_error(self, client: Client, mock_request: MagicMock): + mock_request.return_value = MockResponse(ok=False, value="not allowed") assert client.get(reverse("api:calendar_external")).status_code == 404 def test_fetch_success(self, client: Client, mock_request: MagicMock): - external_response = MockResponse(200, "Definitely an ICS") + external_response = MockResponse(ok=True, value="Definitely an ICS") mock_request.return_value = external_response response = client.get(reverse("api:calendar_external")) assert response.status_code == 200 @@ -120,3 +125,126 @@ class TestInternalCalendar: out_file = accel_redirect_to_file(response) assert out_file is not None assert out_file.exists() + + +@pytest.mark.django_db +class TestModerateNews: + @pytest.mark.parametrize("news_is_published", [True, False]) + def test_moderation_ok(self, client: Client, news_is_published: bool): # noqa FBT + user = baker.make( + User, user_permissions=[Permission.objects.get(codename="moderate_news")] + ) + # The API call should work even if the news is initially moderated. + # In the latter case, the result should be a noop, rather than an error. + news = baker.make(News, is_published=news_is_published) + initial_moderator = news.moderator + client.force_login(user) + response = client.patch( + reverse("api:moderate_news", kwargs={"news_id": news.id}) + ) + # if it wasn't moderated, it should now be moderated and the moderator should + # be the user that made the request. + # If it was already moderated, it should be a no-op, but not an error + assert response.status_code == 200 + news.refresh_from_db() + assert news.is_published + if not news_is_published: + assert news.moderator == user + else: + assert news.moderator == initial_moderator + + def test_moderation_forbidden(self, client: Client): + user = baker.make(User) + news = baker.make(News, is_published=False) + client.force_login(user) + response = client.patch( + reverse("api:moderate_news", kwargs={"news_id": news.id}) + ) + assert response.status_code == 403 + news.refresh_from_db() + assert not news.is_published + + +@pytest.mark.django_db +class TestDeleteNews: + def test_delete_news_ok(self, client: Client): + user = baker.make( + User, user_permissions=[Permission.objects.get(codename="delete_news")] + ) + news = baker.make(News) + client.force_login(user) + response = client.delete( + reverse("api:delete_news", kwargs={"news_id": news.id}) + ) + assert response.status_code == 200 + assert not News.objects.filter(id=news.id).exists() + + def test_delete_news_forbidden(self, client: Client): + user = baker.make(User) + news = baker.make(News) + client.force_login(user) + response = client.delete( + reverse("api:delete_news", kwargs={"news_id": news.id}) + ) + assert response.status_code == 403 + assert News.objects.filter(id=news.id).exists() + + +class TestFetchNewsDates(TestCase): + @classmethod + def setUpTestData(cls): + News.objects.all().delete() + cls.dates = baker.make( + NewsDate, + _quantity=5, + _bulk_create=True, + start_date=seq(value=now(), increment_by=timedelta(days=1)), + end_date=seq( + value=now() + timedelta(hours=2), increment_by=timedelta(days=1) + ), + news=iter( + baker.make(News, is_published=True, _quantity=5, _bulk_create=True) + ), + ) + cls.dates.append( + baker.make( + NewsDate, + start_date=now() + timedelta(days=2, hours=1), + end_date=now() + timedelta(days=2, hours=5), + news=baker.make(News, is_published=True), + ) + ) + cls.dates.sort(key=lambda d: d.start_date) + + def test_num_queries(self): + with assertNumQueries(2): + self.client.get(reverse("api:fetch_news_dates")) + + def test_html_format(self): + """Test that when the summary is asked in html, the summary is in html.""" + summary_1 = "# First event\nThere is something happening.\n" + self.dates[0].news.summary = summary_1 + self.dates[0].news.save() + summary_2 = ( + "# Second event\n" + "There is something happening **for real**.\n" + "Everything is [here](https://youtu.be/dQw4w9WgXcQ)\n" + ) + self.dates[1].news.summary = summary_2 + self.dates[1].news.save() + response = self.client.get( + reverse("api:fetch_news_dates") + "?page_size=2&text_format=html" + ) + assert response.status_code == 200 + dates = response.json()["results"] + assert dates[0]["news"]["summary"] == markdown(summary_1) + assert dates[1]["news"]["summary"] == markdown(summary_2) + + def test_fetch(self): + after = quote((now() + timedelta(days=1)).isoformat()) + response = self.client.get( + reverse("api:fetch_news_dates") + f"?page_size=3&after={after}" + ) + assert response.status_code == 200 + dates = response.json()["results"] + assert [d["id"] for d in dates] == [d.id for d in self.dates[1:4]] diff --git a/com/tests/test_models.py b/com/tests/test_models.py index 41be34ee..f8536722 100644 --- a/com/tests/test_models.py +++ b/com/tests/test_models.py @@ -18,7 +18,7 @@ class TestNewsViewableBy(TestCase): cls.news = baker.make( News, author=itertools.cycle(cls.users), - is_moderated=iter([True, True, True, False, False, False]), + is_published=iter([True, True, True, False, False, False]), _quantity=6, _bulk_create=True, ) diff --git a/com/tests/test_views.py b/com/tests/test_views.py index f80839ab..ce96766e 100644 --- a/com/tests/test_views.py +++ b/com/tests/test_views.py @@ -168,7 +168,7 @@ class TestNews(TestCase): assert not self.new.can_be_viewed_by(self.sli) assert not self.new.can_be_viewed_by(self.anonymous) - self.new.is_moderated = True + self.new.is_published = True self.new.save() assert self.new.can_be_viewed_by(self.com_admin) assert self.new.can_be_viewed_by(self.sli) @@ -258,7 +258,7 @@ class TestNewsCreation(TestCase): created = News.objects.order_by("id").last() assertRedirects(response, created.get_absolute_url()) assert created.title == "Test news" - assert not created.is_moderated + assert not created.is_published dates = list(created.dates.values("start_date", "end_date")) assert dates == [{"start_date": self.start, "end_date": self.end}] @@ -281,7 +281,7 @@ class TestNewsCreation(TestCase): ] def test_edit_news(self): - news = baker.make(News, author=self.user, is_moderated=True) + news = baker.make(News, author=self.user, is_published=True) baker.make( NewsDate, news=news, @@ -296,7 +296,7 @@ class TestNewsCreation(TestCase): created = News.objects.order_by("id").last() assertRedirects(response, created.get_absolute_url()) assert created.title == "Test news" - assert not created.is_moderated + assert not created.is_published dates = list(created.dates.values("start_date", "end_date")) assert dates == [{"start_date": self.start, "end_date": self.end}] diff --git a/com/views.py b/com/views.py index 0ab8fc1c..e1114e57 100644 --- a/com/views.py +++ b/com/views.py @@ -22,7 +22,7 @@ # # import itertools -from datetime import timedelta +from datetime import date, timedelta from smtplib import SMTPRecipientsRefused from typing import Any @@ -37,9 +37,9 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy from django.utils import timezone -from django.utils.timezone import localdate +from django.utils.timezone import localdate, now from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, ListView, View +from django.views.generic import DetailView, ListView, TemplateView, View from django.views.generic.edit import CreateView, DeleteView, UpdateView from club.models import Club, Mailing @@ -217,9 +217,9 @@ class NewsModerateView(PermissionRequiredMixin, DetailView): def get(self, request, *args, **kwargs): self.object = self.get_object() if "remove" in request.GET: - self.object.is_moderated = False + self.object.is_published = False else: - self.object.is_moderated = True + self.object.is_published = True self.object.moderator = request.user self.object.save() if "next" in self.request.GET: @@ -236,28 +236,65 @@ class NewsAdminListView(PermissionRequiredMixin, ListView): permission_required = ["com.moderate_news", "com.delete_news"] -class NewsListView(ListView): - model = News +class NewsListView(TemplateView): template_name = "com/news_list.jinja" - queryset = News.objects.filter(is_moderated=True) - def get_queryset(self): - return super().get_queryset().viewable_by(self.request.user) - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["NewsDate"] = NewsDate - kwargs["timedelta"] = timedelta - kwargs["birthdays"] = itertools.groupby( + def get_birthdays(self): + if not self.request.user.has_perm("core.view_user"): + return [] + return itertools.groupby( User.objects.filter( date_of_birth__month=localdate().month, date_of_birth__day=localdate().day, + is_subscriber_viewable=True, ) .filter(role__in=["STUDENT", "FORMER STUDENT"]) .order_by("-date_of_birth"), key=lambda u: u.date_of_birth.year, ) - return kwargs + + def get_last_day(self) -> date | None: + """Get the last day when news will be displayed + + The returned day is the third one where something happen. + For example, if there are 6 events : A on 15/03, B and C on 17/03, + D on 20/03, E on 21/03 and F on 22/03 ; + then the result is 20/03. + """ + dates = list( + NewsDate.objects.filter(end_date__gt=now()) + .order_by("start_date") + .values_list("start_date__date", flat=True) + .distinct()[:4] + ) + return dates[-1] if len(dates) > 0 else None + + def get_news_dates(self, until: date) -> dict[date, list[date]]: + """Return the event dates to display. + + The selected events are the ones that happens between + right now and the given day (included). + """ + return { + date: list(dates) + for date, dates in itertools.groupby( + NewsDate.objects.viewable_by(self.request.user) + .filter(end_date__gt=now(), start_date__date__lte=until) + .order_by("start_date") + .select_related("news", "news__club"), + key=lambda d: d.start_date.date(), + ) + } + + def get_context_data(self, **kwargs): + last_day = self.get_last_day() + return super().get_context_data(**kwargs) | { + "news_dates": self.get_news_dates(until=last_day) + if last_day is not None + else {}, + "birthdays": self.get_birthdays(), + "last_day": last_day, + } class NewsDetailView(CanViewMixin, DetailView): @@ -278,7 +315,7 @@ class NewsFeed(Feed): def items(self): return ( NewsDate.objects.filter( - news__is_moderated=True, + news__is_published=True, end_date__gte=timezone.now() - (relativedelta(months=6)), ) .select_related("news", "news__author") diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 53638699..21fde2e5 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -169,7 +169,7 @@ class Command(BaseCommand): Weekmail().save() # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment - self.now = timezone.now().replace(hour=12) + self.now = timezone.now().replace(hour=12, second=0) skia = User.objects.create_user( username="skia", @@ -681,7 +681,7 @@ Welcome to the wiki page! friday = self.now while friday.weekday() != 4: friday += timedelta(hours=6) - friday.replace(hour=20, minute=0, second=0) + friday.replace(hour=20, minute=0) # Event news_dates = [] n = News.objects.create( @@ -690,7 +690,7 @@ Welcome to the wiki page! content="Glou glou glou glou glou glou glou", club=bar_club, author=subscriber, - is_moderated=True, + is_published=True, moderator=skia, ) news_dates.append( @@ -704,12 +704,11 @@ Welcome to the wiki page! title="Repas barman", summary="Enjoy la fin du semestre!", content=( - "Viens donc t'enjailler avec les autres barmans aux " - "frais du BdF! \\o/" + "Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/" ), club=bar_club, author=subscriber, - is_moderated=True, + is_published=True, moderator=skia, ) news_dates.append( @@ -725,7 +724,7 @@ Welcome to the wiki page! content="Fô viendre mangey d'la bonne fondue!", club=bar_club, author=subscriber, - is_moderated=True, + is_published=True, moderator=skia, ) news_dates.append( @@ -741,7 +740,7 @@ Welcome to the wiki page! content="Viens faire la fête avec tout plein de gens!", club=bar_club, author=subscriber, - is_moderated=True, + is_published=True, moderator=skia, ) news_dates.append( @@ -759,7 +758,7 @@ Welcome to the wiki page! "t'amuser le Vendredi soir!", club=troll, author=subscriber, - is_moderated=True, + is_published=True, moderator=skia, ) news_dates.extend( diff --git a/core/migrations/0044_alter_userban_options.py b/core/migrations/0044_alter_userban_options.py new file mode 100644 index 00000000..8dd1739e --- /dev/null +++ b/core/migrations/0044_alter_userban_options.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.17 on 2025-02-25 14:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0043_bangroup_alter_group_description_alter_user_groups_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="userban", + options={"verbose_name": "user ban", "verbose_name_plural": "user bans"}, + ), + ] diff --git a/core/static/bundled/user/pictures-index.js b/core/static/bundled/user/pictures-index.js deleted file mode 100644 index 68f08d25..00000000 --- a/core/static/bundled/user/pictures-index.js +++ /dev/null @@ -1,101 +0,0 @@ -import { paginated } from "#core:utils/api"; -import { HttpReader, ZipWriter } from "@zip.js/zip.js"; -import { showSaveFilePicker } from "native-file-system-adapter"; -import { picturesFetchPictures } from "#openapi"; - -/** - * @typedef UserProfile - * @property {number} id - * @property {string} first_name - * @property {string} last_name - * @property {string} nick_name - * @property {string} display_name - * @property {string} profile_url - * @property {string} profile_pict - */ -/** - * @typedef Picture - * @property {number} id - * @property {string} name - * @property {number} size - * @property {string} date - * @property {UserProfile} owner - * @property {string} full_size_url - * @property {string} compressed_url - * @property {string} thumb_url - * @property {string} album - * @property {boolean} is_moderated - * @property {boolean} asked_for_removal - */ - -/** - * @typedef PicturePageConfig - * @property {number} userId Id of the user to get the pictures from - **/ - -/** - * Load user picture page with a nice download bar - * @param {PicturePageConfig} config - **/ -window.loadPicturePage = (config) => { - document.addEventListener("alpine:init", () => { - Alpine.data("user_pictures", () => ({ - isDownloading: false, - loading: true, - pictures: [], - albums: {}, - - async init() { - this.pictures = await paginated(picturesFetchPictures, { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - query: { users_identified: [config.userId] }, - }); - this.albums = this.pictures.reduce((acc, picture) => { - if (!acc[picture.album]) { - acc[picture.album] = []; - } - acc[picture.album].push(picture); - return acc; - }, {}); - this.loading = false; - }, - - async downloadZip() { - this.isDownloading = true; - const bar = this.$refs.progress; - bar.value = 0; - bar.max = this.pictures.length; - - const incrementProgressBar = () => { - bar.value++; - }; - - const fileHandle = await showSaveFilePicker({ - _preferPolyfill: false, - suggestedName: interpolate( - gettext("pictures.%(extension)s"), - { extension: "zip" }, - true, - ), - types: {}, - excludeAcceptAllOption: false, - }); - const zipWriter = new ZipWriter(await fileHandle.createWritable()); - - await Promise.all( - this.pictures.map((p) => { - const imgName = `${p.album}/IMG_${p.date.replaceAll(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; - return zipWriter.add(imgName, new HttpReader(p.full_size_url), { - level: 9, - lastModDate: new Date(p.date), - onstart: incrementProgressBar, - }); - }), - ); - - await zipWriter.close(); - this.isDownloading = false; - }, - })); - }); -}; diff --git a/core/static/core/style.scss b/core/static/core/style.scss index a89d33cf..6b037995 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -244,6 +244,20 @@ body { } } + &.btn-green { + $bg-color: rgba(0, 210, 83, 0.4); + background-color: $bg-color; + color: $black-color; + + &:not(:disabled):hover { + background-color: darken($bg-color, 15%); + } + + &:disabled { + background-color: lighten($bg-color, 15%); + } + } + &.btn-red { background-color: #fc8181; color: black; @@ -258,9 +272,26 @@ body { } } - i { - margin-right: 4px; + &.btn-orange { + background-color: #fcbf81; + color: black; + + &:not(:disabled):hover { + background-color: darken(#fcbf81, 15%); + } + + &:disabled { + background-color: lighten(#fcbf81, 15%); + color: grey; + } } + + &:not(.btn-no-text) { + i { + margin-right: 4px; + } + } + } /** diff --git a/core/templates/core/macros.jinja b/core/templates/core/macros.jinja index 84c5b05a..b43ba844 100644 --- a/core/templates/core/macros.jinja +++ b/core/templates/core/macros.jinja @@ -39,14 +39,6 @@ {%- endmacro %} -{% macro fb_quick(news) -%} - -{%- endmacro %} - -{% macro tweet_quick(news) -%} - -{%- endmacro %} - {% macro user_mini_profile(user) %}
diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 998ceab5..2f86507a 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -64,40 +64,6 @@ class TestImageAccess: assert not picture.is_owned_by(user) -@pytest.mark.django_db -class TestUserPicture: - def test_anonymous_user_unauthorized(self, client): - """An anonymous user shouldn't have access to an user's photo page.""" - response = client.get( - reverse( - "core:user_pictures", - kwargs={"user_id": User.objects.get(username="sli").pk}, - ) - ) - assert response.status_code == 403 - - @pytest.mark.parametrize( - ("username", "status"), - [ - ("guy", 403), - ("root", 200), - ("skia", 200), - ("sli", 200), - ], - ) - def test_page_is_working(self, client, username, status): - """Only user that subscribed (or admins) should be able to see the page.""" - # Test for simple user - client.force_login(User.objects.get(username=username)) - response = client.get( - reverse( - "core:user_pictures", - kwargs={"user_id": User.objects.get(username="sli").pk}, - ) - ) - assert response.status_code == status - - # TODO: many tests on the pages: # - renaming a page # - changing a page's parent --> check that page's children's full_name diff --git a/core/urls.py b/core/urls.py index a3b4f7d8..23fa9f11 100644 --- a/core/urls.py +++ b/core/urls.py @@ -23,6 +23,7 @@ # from django.urls import path, re_path, register_converter +from django.views.generic import RedirectView from core.converters import ( BooleanStringConverter, @@ -68,7 +69,6 @@ from core.views import ( UserGodfathersView, UserListView, UserMiniView, - UserPicturesView, UserPreferencesView, UserStatsView, UserToolsView, @@ -144,7 +144,8 @@ urlpatterns = [ path("user//mini/", UserMiniView.as_view(), name="user_profile_mini"), path("user//", UserView.as_view(), name="user_profile"), path( - "user//pictures/", UserPicturesView.as_view(), name="user_pictures" + "user//pictures/", + RedirectView.as_view(pattern_name="sas:user_pictures", permanent=True), ), path( "user//godfathers/", diff --git a/core/views/user.py b/core/views/user.py index d742a6f5..8e7b092c 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -200,7 +200,7 @@ class UserTabsMixin(TabedViewMixin): "name": _("Family"), }, { - "url": reverse("core:user_pictures", kwargs={"user_id": user.id}), + "url": reverse("sas:user_pictures", kwargs={"user_id": user.id}), "slug": "pictures", "name": _("Pictures"), }, @@ -297,16 +297,6 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView): return kwargs -class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView): - """Display a user's pictures.""" - - model = User - pk_url_kwarg = "user_id" - context_object_name = "profile" - template_name = "core/user_pictures.jinja" - current_tab = "pictures" - - def delete_user_godfather(request, user_id, godfather_id, is_father): user_is_admin = request.user.is_root or request.user.is_board_member if user_id != request.user.id and not user_is_admin: diff --git a/counter/models.py b/counter/models.py index 2ac691e0..1467c9f4 100644 --- a/counter/models.py +++ b/counter/models.py @@ -847,11 +847,10 @@ class Selling(models.Model): verbose_name = _("selling") def __str__(self): - return "Selling: %d x %s (%f) for %s" % ( - self.quantity, - self.label, - self.quantity * self.unit_price, - self.customer.user.get_display_name(), + return ( + f"Selling: {self.quantity} x {self.label} " + f"({self.quantity * self.unit_price} €) " + f"for {self.customer.user.get_display_name()}" ) def save(self, *args, allow_negative=False, **kwargs): @@ -1056,7 +1055,7 @@ class CashRegisterSummary(models.Model): def __getattribute__(self, name): if name[:5] == "check": - checks = self.items.filter(check=True).order_by("value").all() + checks = self.items.filter(is_check=True).order_by("value").all() if name == "ten_cents": return self.items.filter(value=0.1, is_check=False).first() elif name == "twenty_cents": diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 7ab8de1d..b2b63c80 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,146 +1,141 @@ -import { exportToHtml } from "#core:utils/globals"; import { BasketItem } from "#counter:counter/basket"; import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; +import type { CounterProductSelect } from "./components/counter-product-select-index"; -exportToHtml("loadCounter", (config: CounterConfig) => { - document.addEventListener("alpine:init", () => { - Alpine.data("counter", () => ({ - basket: {} as Record, - errors: [], - customerBalance: config.customerBalance, - codeField: undefined, - alertMessage: { - content: "", - show: false, - timeout: null, - }, +document.addEventListener("alpine:init", () => { + Alpine.data("counter", (config: CounterConfig) => ({ + basket: {} as Record, + errors: [], + customerBalance: config.customerBalance, + codeField: null as CounterProductSelect | null, + alertMessage: { + content: "", + show: false, + timeout: null, + }, - init() { - // Fill the basket with the initial data - for (const entry of config.formInitial) { - if (entry.id !== undefined && entry.quantity !== undefined) { - this.addToBasket(entry.id, entry.quantity); - this.basket[entry.id].errors = entry.errors ?? []; - } + init() { + // Fill the basket with the initial data + for (const entry of config.formInitial) { + if (entry.id !== undefined && entry.quantity !== undefined) { + this.addToBasket(entry.id, entry.quantity); + this.basket[entry.id].errors = entry.errors ?? []; } + } - this.codeField = this.$refs.codeField; - this.codeField.widget.focus(); + this.codeField = this.$refs.codeField; + this.codeField.widget.focus(); - // It's quite tricky to manually apply attributes to the management part - // of a formset so we dynamically apply it here - this.$refs.basketManagementForm - .querySelector("#id_form-TOTAL_FORMS") - .setAttribute(":value", "getBasketSize()"); - }, + // It's quite tricky to manually apply attributes to the management part + // of a formset so we dynamically apply it here + this.$refs.basketManagementForm + .querySelector("#id_form-TOTAL_FORMS") + .setAttribute(":value", "getBasketSize()"); + }, - removeFromBasket(id: string) { + removeFromBasket(id: string) { + delete this.basket[id]; + }, + + addToBasket(id: string, quantity: number): ErrorMessage { + const item: BasketItem = + this.basket[id] || new BasketItem(config.products[id], 0); + + const oldQty = item.quantity; + item.quantity += quantity; + + if (item.quantity <= 0) { delete this.basket[id]; - }, - - addToBasket(id: string, quantity: number): ErrorMessage { - const item: BasketItem = - this.basket[id] || new BasketItem(config.products[id], 0); - - const oldQty = item.quantity; - item.quantity += quantity; - - if (item.quantity <= 0) { - delete this.basket[id]; - return ""; - } - - this.basket[id] = item; - - if (this.sumBasket() > this.customerBalance) { - item.quantity = oldQty; - if (item.quantity === 0) { - delete this.basket[id]; - } - return gettext("Not enough money"); - } - return ""; - }, + } - getBasketSize() { - return Object.keys(this.basket).length; - }, + this.basket[id] = item; - sumBasket() { - if (this.getBasketSize() === 0) { - return 0; + if (this.sumBasket() > this.customerBalance) { + item.quantity = oldQty; + if (item.quantity === 0) { + delete this.basket[id]; } - const total = Object.values(this.basket).reduce( - (acc: number, cur: BasketItem) => acc + cur.sum(), - 0, - ) as number; - return total; - }, + return gettext("Not enough money"); + } - showAlertMessage(message: string) { - if (this.alertMessage.timeout !== null) { - clearTimeout(this.alertMessage.timeout); + return ""; + }, + + getBasketSize() { + return Object.keys(this.basket).length; + }, + + sumBasket() { + if (this.getBasketSize() === 0) { + return 0; + } + const total = Object.values(this.basket).reduce( + (acc: number, cur: BasketItem) => acc + cur.sum(), + 0, + ) as number; + return total; + }, + + showAlertMessage(message: string) { + if (this.alertMessage.timeout !== null) { + clearTimeout(this.alertMessage.timeout); + } + this.alertMessage.content = message; + this.alertMessage.show = true; + this.alertMessage.timeout = setTimeout(() => { + this.alertMessage.show = false; + this.alertMessage.timeout = null; + }, 2000); + }, + + addToBasketWithMessage(id: string, quantity: number) { + const message = this.addToBasket(id, quantity); + if (message.length > 0) { + this.showAlertMessage(message); + } + }, + + onRefillingSuccess(event: CustomEvent) { + if (event.type !== "htmx:after-request" || event.detail.failed) { + return; + } + this.customerBalance += Number.parseFloat( + (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, + ); + document.getElementById("selling-accordion").click(); + this.codeField.widget.focus(); + }, + + finish() { + if (this.getBasketSize() === 0) { + this.showAlertMessage(gettext("You can't send an empty basket.")); + return; + } + this.$refs.basketForm.submit(); + }, + + cancel() { + location.href = config.cancelUrl; + }, + + handleCode() { + const [quantity, code] = this.codeField.getSelectedProduct() as [number, string]; + + if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { + if (code === "ANN") { + this.cancel(); } - this.alertMessage.content = message; - this.alertMessage.show = true; - this.alertMessage.timeout = setTimeout(() => { - this.alertMessage.show = false; - this.alertMessage.timeout = null; - }, 2000); - }, - - addToBasketWithMessage(id: string, quantity: number) { - const message = this.addToBasket(id, quantity); - if (message.length > 0) { - this.showAlertMessage(message); + if (code === "FIN") { + this.finish(); } - }, - - onRefillingSuccess(event: CustomEvent) { - if (event.type !== "htmx:after-request" || event.detail.failed) { - return; - } - this.customerBalance += Number.parseFloat( - (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, - ); - document.getElementById("selling-accordion").click(); - this.codeField.widget.focus(); - }, - - finish() { - if (this.getBasketSize() === 0) { - this.showAlertMessage(gettext("You can't send an empty basket.")); - return; - } - this.$refs.basketForm.submit(); - }, - - cancel() { - location.href = config.cancelUrl; - }, - - handleCode() { - const [quantity, code] = this.codeField.getSelectedProduct() as [ - number, - string, - ]; - - if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { - if (code === "ANN") { - this.cancel(); - } - if (code === "FIN") { - this.finish(); - } - } else { - this.addToBasketWithMessage(code, quantity); - } - this.codeField.widget.clear(); - this.codeField.widget.focus(); - }, - })); - }); + } else { + this.addToBasketWithMessage(code, quantity); + } + this.codeField.widget.clear(); + this.codeField.widget.focus(); + }, + })); }); $(() => { diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 82707e2e..0054f35d 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -27,7 +27,13 @@ {% block content %}

{{ counter }}

-
+
@@ -255,14 +261,5 @@ {%- endif -%} {%- endfor -%} ]; - window.addEventListener("DOMContentLoaded", () => { - loadCounter({ - customerBalance: {{ customer.amount }}, - products: products, - customerId: {{ customer.pk }}, - formInitial: formInitial, - cancelUrl: "{{ cancel_url }}", - }); - }); {% endblock script %} \ No newline at end of file diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index 27ce62bc..d50bb6c4 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -681,6 +681,42 @@ class TestCounterClick(TestFullClickBase): -3 - settings.SITH_ECOCUP_LIMIT ) + def test_recordings_when_negative(self): + self.refill_user( + self.customer, + self.cons.selling_price * 3 + Decimal(self.beer.selling_price), + ) + self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10 + self.customer.customer.save() + self.login_in_bar(self.barmen) + assert ( + self.submit_basket( + self.customer, + [BasketItem(self.dcons.id, 1)], + ).status_code + == 200 + ) + assert self.updated_amount( + self.customer + ) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price) + assert ( + self.submit_basket( + self.customer, + [BasketItem(self.cons.id, 3)], + ).status_code + == 302 + ) + assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price) + + assert ( + self.submit_basket( + self.customer, + [BasketItem(self.beer.id, 1)], + ).status_code + == 302 + ) + assert self.updated_amount(self.customer) == 0 + class TestCounterStats(TestCase): @classmethod diff --git a/counter/views/click.py b/counter/views/click.py index eb6f8e28..46bf8e62 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -126,6 +126,11 @@ class BaseBasketForm(BaseFormSet): if form.product.is_unrecord_product: self.total_recordings += form.cleaned_data["quantity"] + # We don't want to block an user that have negative recordings + # if he isn't recording anything or reducing it's recording count + if self.total_recordings <= 0: + return + if not customer.can_record_more(self.total_recordings): raise ValidationError(_("This user have reached his recording limit")) diff --git a/docs/howto/prod.md b/docs/howto/prod.md index 769f681f..df7c7644 100644 --- a/docs/howto/prod.md +++ b/docs/howto/prod.md @@ -2,13 +2,13 @@ Pour connecter l'application à une instance de sentry (ex: https://sentry.io), il est nécessaire de configurer la variable `SENTRY_DSN` -dans le fichier `settings_custom.py`. +dans le fichier `.env`. Cette variable est composée d'un lien complet vers votre projet sentry. ## Récupérer les statiques Nous utilisons du SCSS dans le projet. -En environnement de développement (`DEBUG=True`), +En environnement de développement (`SITH_DEBUG=true`), le SCSS est compilé à chaque fois que le fichier est demandé. Pour la production, le projet considère que chacun des fichiers est déjà compilé. diff --git a/docs/tutorial/install-advanced.md b/docs/tutorial/install-advanced.md index 318166ab..7b4fe493 100644 --- a/docs/tutorial/install-advanced.md +++ b/docs/tutorial/install-advanced.md @@ -47,19 +47,19 @@ Commencez par installer les dépendances système : === "Debian/Ubuntu" ```bash - sudo apt install postgresql redis libq-dev nginx + sudo apt install postgresql libq-dev nginx ``` === "Arch Linux" ```bash - sudo pacman -S postgresql redis nginx + sudo pacman -S postgresql nginx ``` === "macOS" ```bash - brew install postgresql redis lipbq nginx + brew install postgresql lipbq nginx export PATH="/usr/local/opt/libpq/bin:$PATH" source ~/.zshrc ``` @@ -77,34 +77,6 @@ uv sync --group prod C'est parce que ces dépendances compilent certains modules à l'installation. -## Configurer Redis - -Redis est utilisé comme cache. -Assurez-vous qu'il tourne : - -```bash -sudo systemctl redis status -``` - -Et s'il ne tourne pas, démarrez-le : - -```bash -sudo systemctl start redis -sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot -``` - -Puis ajoutez le code suivant à la fin de votre fichier -`settings_custom.py` : - -```python -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": "redis://127.0.0.1:6379", - } -} -``` - ## Configurer PostgreSQL PostgreSQL est utilisé comme base de données. @@ -139,26 +111,19 @@ en étant connecté en tant que postgres : psql -d sith -c "GRANT ALL PRIVILEGES ON SCHEMA public to sith"; ``` -Puis ajoutez le code suivant à la fin de votre -`settings_custom.py` : +Puis modifiez votre `.env`. +Dedans, décommentez l'url de la base de données +de postgres et commentez l'url de sqlite : -```python -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "sith", - "USER": "sith", - "PASSWORD": "password", - "HOST": "localhost", - "PORT": "", # laissez ce champ vide pour que le choix du port soit automatique - } -} +```dotenv +#DATABASE_URL=sqlite:///db.sqlite3 +DATABASE_URL=postgres://sith:password@localhost:5432/sith ``` Enfin, créez vos données : ```bash -uv run ./manage.py populate +uv run ./manage.py setup ``` !!! note @@ -247,7 +212,7 @@ Puis lancez ou relancez nginx : sudo systemctl restart nginx ``` -Dans votre `settings_custom.py`, remplacez `DEBUG=True` par `DEBUG=False`. +Dans votre `.env`, remplacez `SITH_DEBUG=true` par `SITH_DEBUG=false`. Enfin, démarrez le serveur Django : @@ -259,7 +224,7 @@ uv run ./manage.py runserver 8001 Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur. Nginx écoutera sur le port 8000. Toutes les requêtes vers des fichiers statiques et les medias publiques -seront seront servies directement par nginx. +seront servies directement par nginx. Toutes les autres requêtes seront transmises au serveur django. @@ -273,3 +238,64 @@ un cron pour la mettre à jour au moins une fois par jour. ```bash python manage.py update_spam_database ``` + +## Personnaliser l'environnement + +Le site utilise beaucoup de variables configurables via l'environnement. +Cependant, pour des raisons de maintenabilité et de simplicité +pour les nouveaux développeurs, nous n'avons mis dans le fichier +`.env.example` que celles qui peuvent nécessiter d'être fréquemment modifiées +(par exemple, l'url de connexion à la db, ou l'activation du mode debug). + +Cependant, il en existe beaucoup d'autres, que vous pouvez trouver +dans le `settings.py` en recherchant `env.` +(avec `grep` ou avec un ++ctrl+f++ dans votre éditeur). + +Si le besoin de les modifier se présente, c'est chose possible. +Il suffit de rajouter la paire clef-valeur correspondante dans le `.env`. + +!!!tip + + Si vous utilisez nushell, + vous pouvez automatiser le processus avec + avec le script suivant, qui va parser le `settings.py` + pour récupérer toutes les variables d'environnement qui ne sont pas + définies dans le .env puis va les rajouter : + + ```nu + # si le fichier .env n'existe pas, on le crée + if not (".env" | path exists) { + cp .env.example .env + } + + # puis on récupère les variables d'environnement déjà existantes + let existing = open .env + + # on récupère toutes les variables d'environnement utilisées + # dans le settings.py qui ne sont pas encore définies dans le .env, + # on les convertit dans un format .env, + # puis on les ajoute à la fin du .env + let regex = '(env\.)(?\w+)\(\s*"(?\w+)"(\s*(, default=)(?.+))?\s*\)'; + let content = open sith/settings.py; + let vars = $content + | parse --regex $regex + | filter { |i| $i.env_name not-in $existing } + | each { |i| + let parsed_value = match [$i.method, $i.value] { + ["str", "None"] => "" + ["bool", $val] => ($val | str downcase) + ["list", $val] => ($val | str trim -c '[' | str trim -c ']') + ["path", $val] => ($val | str replace 'BASE_DIR / "' $'"(pwd)/') + [_, $val] => $val + } + $"($i.env_name)=($parsed_value)" + } + + if ($vars | is-not-empty) { + # on ajoute les nouvelles valeurs, + # en mettant une ligne vide de séparation avec les anciennes + ["", ...$vars] | save --append .env + } + + print $"($vars | length) values added to .env" + ``` \ No newline at end of file diff --git a/docs/tutorial/install.md b/docs/tutorial/install.md index bdd2cfc5..0a621587 100644 --- a/docs/tutorial/install.md +++ b/docs/tutorial/install.md @@ -7,6 +7,7 @@ Certaines dépendances sont nécessaires niveau système : - libjpeg - zlib1g-dev - gettext +- redis ### Installer WSL @@ -65,8 +66,8 @@ cd /mnt//vos/fichiers/comme/dhab ```bash sudo apt install curl build-essential libssl-dev \ - libjpeg-dev zlib1g-dev npm libffi-dev pkg-config \ - gettext git + libjpeg-dev zlib1g-dev npm libffi-dev pkg-config \ + gettext git redis curl -LsSf https://astral.sh/uv/install.sh | sh ``` @@ -75,7 +76,7 @@ cd /mnt//vos/fichiers/comme/dhab ```bash sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour - sudo pacman -S uv gcc git gettext pkgconf npm + sudo pacman -S uv gcc git gettext pkgconf npm redis ``` === "macOS" @@ -84,7 +85,7 @@ cd /mnt//vos/fichiers/comme/dhab Il est également nécessaire d'avoir installé xcode ```bash - brew install git uv npm + brew install git uv npm redis # Pour bien configurer gettext brew link gettext # (suivez bien les instructions supplémentaires affichées) @@ -99,6 +100,15 @@ cd /mnt//vos/fichiers/comme/dhab Python ne fait pas parti des dépendances puisqu'il est automatiquement installé par uv. +Parmi les dépendances installées se trouve redis (que nous utilisons comme cache). +Redis est un service qui doit être activé pour être utilisé. +Pour cela, effectuez les commandes : + +```bash +sudo systemctl start redis +sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot +``` + ## Finaliser l'installation Clonez le projet (depuis votre console WSL, si vous utilisez WSL) @@ -120,20 +130,24 @@ uv run ./manage.py install_xapian de texte à l'écran. C'est normal, il ne faut pas avoir peur. -Maintenant que les dépendances sont installées, nous -allons créer la base de données, la remplir avec des données de test, -et compiler les traductions. -Cependant, avant de faire cela, il est nécessaire de modifier -la configuration pour signifier que nous sommes en mode développement. -Pour cela, nous allons créer un fichier `sith/settings_custom.py` -et l'utiliser pour surcharger les settings de base. +Une fois les dépendances installées, il faut encore +mettre en place quelques éléments de configuration, +qui peuvent varier d'un environnement à l'autre. +Ces variables sont stockées dans un fichier `.env`. +Pour le créer, vous pouvez copier le fichier `.env.example` : ```bash -echo "DEBUG=True" > sith/settings_custom.py -echo 'SITH_URL = "localhost:8000"' >> sith/settings_custom.py +cp .env.example .env ``` -Enfin, nous pouvons lancer les commandes suivantes : +Les variables par défaut contenues dans le fichier `.env` +devraient convenir pour le développement, sans modification. + +Maintenant que les dépendances sont installées +et la configuration remplie, nous allons pouvoir générer +des données utiles pendant le développement. + +Pour cela, lancez les commandes suivantes : ```bash # Prépare la base de données @@ -171,6 +185,30 @@ uv run ./manage.py runserver [http://localhost:8000/api/docs](http://localhost:8000/api/docs), une interface swagger, avec toutes les routes de l'API. +!!! question "Pourquoi l'installation est aussi complexe ?" + + Cette question nous a été posée de nombreuses fois par des personnes + essayant d'installer le projet. + Il y a en effet un certain nombre d'étapes à suivre, + de paquets à installer et de commandes à exécuter. + + Le processus d'installation peut donc sembler complexe. + + En réalité, il est difficile de faire plus simple. + En effet, un site web a besoin de beaucoup de composants + pour être développé : il lui faut au minimum + une base de données, un cache, un bundler Javascript + et un interpréteur pour le code du serveur. + Pour nos besoin particuliers, nous utilisons également + un moteur de recherche full-text. + + Nous avons tenté au maximum de limiter le nombre de dépendances + et de sélecionner les plus simples à installer. + Cependant, il est impossible de retirer l'intégralité + de la complexité du processus. + Si vous rencontrez des difficulté lors de l'installation, + n'hésitez pas à demander de l'aide. + ## Générer la documentation La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub. diff --git a/docs/tutorial/structure.md b/docs/tutorial/structure.md index bc3fed36..aff331d2 100644 --- a/docs/tutorial/structure.md +++ b/docs/tutorial/structure.md @@ -72,12 +72,14 @@ sith/ ├── .gitattributes ├── .gitignore ├── .mailmap -├── manage.py (26) -├── mkdocs.yml (27) +├── .env (26) +├── .env.example (27) +├── manage.py (28) +├── mkdocs.yml (29) ├── uv.lock -├── pyproject.toml (28) -├── .venv/ (29) -├── .python-version (30) +├── pyproject.toml (30) +├── .venv/ (31) +├── .python-version (32) └── README.md ```
@@ -121,15 +123,19 @@ sith/ de manière transparente pour l'utilisateur. 24. Fichier de configuration de coverage. 25. Fichier de configuration de direnv. -26. Fichier généré automatiquement par Django. C'est lui +26. Contient les variables d'environnement, qui sont susceptibles + de varier d'une machine à l'autre. +27. Contient des valeurs par défaut pour le `.env` + pouvant convenir à un environnment de développement local +28. Fichier généré automatiquement par Django. C'est lui qui permet d'appeler des commandes de gestion du projet avec la syntaxe `python ./manage.py ` -27. Le fichier de configuration de la documentation, +29. Le fichier de configuration de la documentation, avec ses plugins et sa table des matières. -28. Le fichier où sont déclarés les dépendances et la configuration +30. Le fichier où sont déclarés les dépendances et la configuration de certaines d'entre elles. -29. Dossier d'environnement virtuel généré par uv -30. Fichier qui contrôle quel version de python utiliser pour le projet +31. Dossier d'environnement virtuel généré par uv +32. Fichier qui contrôle quelle version de python utiliser pour le projet ## L'application principale @@ -144,10 +150,9 @@ Il est organisé comme suit : ``` sith/ ├── settings.py (1) -├── settings_custom.py (2) -├── toolbar_debug.py (3) -├── urls.py (4) -└── wsgi.py (5) +├── toolbar_debug.py (2) +├── urls.py (3) +└── wsgi.py (4) ```
@@ -155,13 +160,10 @@ sith/ Ce fichier contient les paramètres de configuration du projet. Par exemple, il contient la liste des applications installées dans le projet. -2. Configuration maison pour votre environnement. - Toute variable que vous définissez dans ce fichier sera prioritaire - sur la configuration donnée dans `settings.py`. -3. Configuration de la barre de debug. +2. Configuration de la barre de debug. C'est inutilisé en prod, mais c'est très pratique en développement. -4. Fichier de configuration des urls du projet. -5. Fichier de configuration pour le serveur WSGI. +3. Fichier de configuration des urls du projet. +4. Fichier de configuration pour le serveur WSGI. WSGI est un protocole de communication entre le serveur et les applications. Ce fichier ne vous servira sans doute pas sur un environnement diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 0cfef902..19b164df 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-12 15:55+0100\n" +"POT-Creation-Date: 2025-02-25 16:38+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -310,7 +310,7 @@ msgstr "Compte en banque : " #: accounting/templates/accounting/club_account_details.jinja #: accounting/templates/accounting/label_list.jinja #: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja -#: com/templates/com/mailing_admin.jinja +#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja #: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja #: core/templates/core/file_detail.jinja @@ -812,7 +812,7 @@ msgstr "Nouvelle mailing liste" msgid "Subscribe" msgstr "S'abonner" -#: club/forms.py com/templates/com/news_admin_list.jinja +#: club/forms.py msgid "Remove" msgstr "Retirer" @@ -1267,7 +1267,7 @@ msgstr "Format : 16:9 | Résolution : 1920x1080" msgid "Start date" msgstr "Date de début" -#: com/forms.py +#: com/forms.py com/templates/com/macros.jinja msgid "Weekly event" msgstr "Événement Hebdomadaire" @@ -1296,8 +1296,8 @@ msgstr "" "Combien de fois l'événement doit-il se répéter (en incluant la première fois)" #: com/forms.py -msgid "Automoderation" -msgstr "Automodération" +msgid "Auto publication" +msgstr "Publication automatique" #: com/models.py msgid "alert message" @@ -1344,6 +1344,10 @@ msgstr "Le club qui organise l'évènement." msgid "author" msgstr "auteur" +#: com/models.py +msgid "is published" +msgstr "est publié" + #: com/models.py msgid "news" msgstr "nouvelle" @@ -1408,14 +1412,43 @@ msgstr "temps d'affichage" msgid "Begin date should be before end date" msgstr "La date de début doit être avant celle de fin" +#: com/templates/com/macros.jinja +msgid "Waiting publication" +msgstr "En attente de publication" + +#: com/templates/com/macros.jinja +msgid "" +"This news isn't published and is visible only by its author and the " +"communication admins." +msgstr "" +"Cette nouvelle n'est pas publiée et n'est visible que par son auteur et les " +"admins communication." + +#: com/templates/com/macros.jinja +msgid "It will stay hidden for other users until it has been published." +msgstr "" +"Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas " +"publiée." + +#: com/templates/com/macros.jinja com/templates/com/news_admin_list.jinja +#: com/templates/com/news_detail.jinja +msgid "Publish" +msgstr "Publier" + +#: com/templates/com/macros.jinja +msgid "News published" +msgstr "Nouvelle publiée" + +#: com/templates/com/macros.jinja +msgid "News deleted" +msgstr "Nouvelle supprimée" + #: com/templates/com/mailing_admin.jinja com/views.py #: core/templates/core/user_tools.jinja msgid "Mailing lists administration" msgstr "Administration des mailing listes" -#: com/templates/com/mailing_admin.jinja -#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja -#: core/templates/core/file_detail.jinja +#: com/templates/com/mailing_admin.jinja core/templates/core/file_detail.jinja #: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja #: sas/templates/sas/picture.jinja msgid "Moderate" @@ -1488,6 +1521,10 @@ msgstr "Modérateur" msgid "Dates" msgstr "Dates" +#: com/templates/com/news_admin_list.jinja +msgid "Unpublish" +msgstr "Dépublier" + #: com/templates/com/news_admin_list.jinja msgid "Weeklies to moderate" msgstr "Nouvelles hebdomadaires à modérer" @@ -1541,6 +1578,17 @@ msgstr "Administrer les news" msgid "Nothing to come..." msgstr "Rien à venir..." +#: com/templates/com/news_list.jinja +msgid "See more" +msgstr "Voir plus" + +#: com/templates/com/news_list.jinja +msgid "" +"It was too short. You already reached the end of the upcoming events list." +msgstr "" +"C'était trop court. Vous êtes déjà arrivés à la fin de la liste des " +"événements à venir." + #: com/templates/com/news_list.jinja msgid "All coming events" msgstr "Tous les événements à venir" @@ -1578,14 +1626,6 @@ msgstr "Discord AE" msgid "Dev Team" msgstr "Pôle Informatique" -#: com/templates/com/news_list.jinja -msgid "Facebook" -msgstr "Facebook" - -#: com/templates/com/news_list.jinja -msgid "Instagram" -msgstr "Instagram" - #: com/templates/com/news_list.jinja msgid "Birthdays" msgstr "Anniversaires" @@ -1599,6 +1639,10 @@ msgstr "%(age)s ans" msgid "You need to subscribe to access this content" msgstr "Vous devez cotiser pour accéder à ce contenu" +#: com/templates/com/news_list.jinja +msgid "You cannot access this content" +msgstr "Vous n'avez pas accès à ce contenu" + #: com/templates/com/poster_edit.jinja com/templates/com/poster_list.jinja msgid "Poster" msgstr "Affiche" @@ -3042,20 +3086,6 @@ msgstr "Éditer les groupes pour %(user_name)s" msgid "User list" msgstr "Liste d'utilisateurs" -#: core/templates/core/user_pictures.jinja -#, python-format -msgid "%(user_name)s's pictures" -msgstr "Photos de %(user_name)s" - -#: core/templates/core/user_pictures.jinja -msgid "Download all my pictures" -msgstr "Télécharger toutes mes photos" - -#: core/templates/core/user_pictures.jinja sas/templates/sas/album.jinja -#: sas/templates/sas/macros.jinja -msgid "To be moderated" -msgstr "A modérer" - #: core/templates/core/user_preferences.jinja core/views/user.py msgid "Preferences" msgstr "Préférences" @@ -4948,6 +4978,10 @@ msgstr "Département" msgid "Credit type" msgstr "Type de crédit" +#: pedagogy/templates/pedagogy/guide.jinja +msgid "closed uv" +msgstr "uv fermée" + #: pedagogy/templates/pedagogy/macros.jinja msgid " not rated " msgstr "non noté" @@ -5185,6 +5219,15 @@ msgstr "SAS" msgid "Albums" msgstr "Albums" +#: sas/templates/sas/album.jinja +msgid "Download album" +msgstr "Télécharger l'album" + +#: sas/templates/sas/album.jinja sas/templates/sas/macros.jinja +#: sas/templates/sas/user_pictures.jinja +msgid "To be moderated" +msgstr "A modérer" + #: sas/templates/sas/album.jinja msgid "Upload" msgstr "Envoyer" @@ -5254,6 +5297,15 @@ msgstr "Personne(s)" msgid "Identify users on pictures" msgstr "Identifiez les utilisateurs sur les photos" +#: sas/templates/sas/user_pictures.jinja +#, python-format +msgid "%(user_name)s's pictures" +msgstr "Photos de %(user_name)s" + +#: sas/templates/sas/user_pictures.jinja +msgid "Download all my pictures" +msgstr "Télécharger toutes mes photos" + #: sith/settings.py msgid "English" msgstr "Anglais" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 28177988..c222636a 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-01-08 12:23+0100\n" +"POT-Creation-Date: 2025-02-25 16:10+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -21,6 +21,27 @@ msgstr "" msgid "More info" msgstr "Plus d'informations" +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "Publish" +msgstr "Publier" + +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "Unpublish" +msgstr "Dépublier" + +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "Delete" +msgstr "Supprimer" + +#: com/static/bundled/com/components/moderation-alert-index.ts +msgid "" +"This event will take place every week for %s weeks. If you publish or delete " +"this event, it will also be published (or deleted) for the following weeks." +msgstr "" +"Cet événement se déroulera chaque semaine pendant %s semaines. Si vous " +"publiez ou supprimez cet événement, il sera également publié (ou supprimé) " +"pour les semaines suivantes." + #: core/static/bundled/core/components/ajax-select-base.ts msgid "Remove" msgstr "Retirer" @@ -125,10 +146,6 @@ msgstr "Montrer plus" msgid "family_tree.%(extension)s" msgstr "arbre_genealogique.%(extension)s" -#: core/static/bundled/user/pictures-index.js -msgid "pictures.%(extension)s" -msgstr "photos.%(extension)s" - #: core/static/user/js/user_edit.js #, javascript-format msgid "captured.%s" @@ -187,6 +204,10 @@ msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgid "Incorrect value" msgstr "Valeur incorrecte" +#: sas/static/bundled/sas/pictures-download-index.ts +msgid "pictures.%(extension)s" +msgstr "photos.%(extension)s" + #: sas/static/bundled/sas/viewer-index.ts msgid "Couldn't moderate picture" msgstr "Il n'a pas été possible de modérer l'image" diff --git a/mkdocs.yml b/mkdocs.yml index f307cb8a..9a7c3114 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -157,6 +157,7 @@ markdown_extensions: - md_in_html - pymdownx.details - pymdownx.inlinehilite + - pymdownx.keys - pymdownx.superfences: custom_fences: - name: mermaid diff --git a/pedagogy/api.py b/pedagogy/api.py index e8d34351..9ad0c3f6 100644 --- a/pedagogy/api.py +++ b/pedagogy/api.py @@ -10,13 +10,13 @@ from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseS from core.auth.api_permissions import HasPerm from pedagogy.models import UV from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema -from pedagogy.utbm_api import find_uv +from pedagogy.utbm_api import UtbmApiClient @api_controller("/uv") class UvController(ControllerBase): @route.get( - "/{year}/{code}", + "/{code}", permissions=[ # this route will almost always be called in the context # of a UV creation/edition @@ -26,10 +26,14 @@ class UvController(ControllerBase): response=UvSchema, ) def fetch_from_utbm_api( - self, year: Annotated[int, Ge(2010)], code: str, lang: Query[str] = "fr" + self, + code: str, + lang: Query[str] = "fr", + year: Query[Annotated[int, Ge(2010)] | None] = None, ): """Fetch UV data from the UTBM API and returns it after some parsing.""" - res = find_uv(lang, year, code) + with UtbmApiClient() as client: + res = client.find_uv(lang, code, year) if res is None: raise NotFound return res @@ -42,4 +46,4 @@ class UvController(ControllerBase): ) @paginate(PageNumberPaginationExtra, page_size=100) def fetch_uv_list(self, search: Query[UvFilterSchema]): - return search.filter(UV.objects.values()) + return search.filter(UV.objects.order_by("code").values()) diff --git a/pedagogy/management/__init__.py b/pedagogy/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pedagogy/management/commands/__init__.py b/pedagogy/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pedagogy/management/commands/update_uv_guide.py b/pedagogy/management/commands/update_uv_guide.py new file mode 100644 index 00000000..cf525a1f --- /dev/null +++ b/pedagogy/management/commands/update_uv_guide.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.core.management import BaseCommand + +from core.models import User +from pedagogy.models import UV +from pedagogy.schemas import UvSchema +from pedagogy.utbm_api import UtbmApiClient + + +class Command(BaseCommand): + help = "Update the UV guide" + + def handle(self, *args, **options): + seen_uvs: set[int] = set() + root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) + with UtbmApiClient() as client: + self.stdout.write( + "Fetching UVs from the UTBM API.\n" + "This may take a few minutes to complete." + ) + for uv in client.fetch_uvs(): + db_uv = UV.objects.filter(code=uv.code).first() + if db_uv is None: + db_uv = UV(code=uv.code, author=root_user) + fields = list(UvSchema.model_fields.keys()) + fields.remove("id") + fields.remove("code") + for field in fields: + setattr(db_uv, field, getattr(uv, field)) + db_uv.save() + # if it's a creation, django will set the id when saving, + # so at this point, a db_uv will always have an id + seen_uvs.add(db_uv.id) + # UVs that are in database but have not been returned by the API + # are considered as closed UEs + UV.objects.exclude(id__in=seen_uvs).update(semester="CLOSED") + self.stdout.write(self.style.SUCCESS("UV guide updated successfully")) diff --git a/pedagogy/schemas.py b/pedagogy/schemas.py index 716e9a3c..cbcd9157 100644 --- a/pedagogy/schemas.py +++ b/pedagogy/schemas.py @@ -54,11 +54,11 @@ class UtbmFullUvSchema(Schema): code: str departement: str = "NA" - libelle: str - objectifs: str - programme: str - acquisition_competences: str - acquisition_notions: str + libelle: str | None + objectifs: str | None + programme: str | None + acquisition_competences: str | None + acquisition_notions: str | None langue: str code_langue: str credits_ects: int diff --git a/pedagogy/static/pedagogy/css/pedagogy.scss b/pedagogy/static/pedagogy/css/pedagogy.scss index a4ebb370..51656615 100644 --- a/pedagogy/static/pedagogy/css/pedagogy.scss +++ b/pedagogy/static/pedagogy/css/pedagogy.scss @@ -47,11 +47,14 @@ $large-devices: 992px; } } - #dynamic_view { + #uv-list { font-size: 1.1em; overflow-wrap: break-word; - + .closed td.title { + color: lighten($black-color, 10%); + font-style: italic; + } td { text-align: center; border: none; diff --git a/pedagogy/templates/pedagogy/guide.jinja b/pedagogy/templates/pedagogy/guide.jinja index 79b66c24..460fdcc5 100644 --- a/pedagogy/templates/pedagogy/guide.jinja +++ b/pedagogy/templates/pedagogy/guide.jinja @@ -85,7 +85,7 @@
- +
@@ -102,11 +102,17 @@ {% endif %} - +
{% trans %}UV{% endtrans %}