Merge pull request #1034 from ae-utbm/taiste

Great news improvements, .env for configuration, full uv guide update command and more
This commit is contained in:
thomas girod 2025-02-25 19:00:35 +01:00 committed by GitHub
commit 2b99da5a37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 2284 additions and 959 deletions

11
.env.example Normal file
View File

@ -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

View File

@ -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)

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ node_modules/
# compiled documentation
site/
.env

View File

@ -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()

View File

@ -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")
)

View File

@ -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")

View File

@ -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

View File

@ -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"),
),
]

View File

@ -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)
)

58
com/schemas.py Normal file
View File

@ -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

View File

@ -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 = `<i class="fa fa-check"></i>${gettext("Publish")}`;
button.setAttribute("class", "btn btn-green");
button.onclick = () => {
this.publishNews(newsId);
};
div.appendChild(button);
} else {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-times"></i>${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 = `<i class="fa fa-trash-can"></i>${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());

View File

@ -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<number> {
// 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],
);
},
}));
});

View File

@ -0,0 +1,67 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
// 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<string, NewsDateSchema[]> {
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<string, ParsedNewsDateSchema[]>, date: ParsedNewsDateSchema) => {
const key = date.start_date.toDateString();
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(date);
return acc;
},
{},
);
},
}));
});

View File

@ -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;
}

View File

@ -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
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news, user, "state") }}
</div>
```
With an id :
```jinja
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news.id, user, "state") }}
</div>
```
An with an alpine variable
```jinja
<div x-data="{state: AlertState.PENDING, newsId: {{ news.id }}">
{{ news_moderation_alert("newsId", user, "state") }}
</div>
```
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.
#}
<div
{% if news is integer or news is string %}
x-data="moderationAlert({{ news }})"
{% else %}
x-data="moderationAlert({{ news.id }})"
{% endif %}
{# the news-moderated is received when a moderation alert is deleted or moderated #}
@news-moderated.window="dispatchModeration($event)"
{% if alpineState %}
x-model="{{ alpineState }}"
x-modelable="state"
{% endif %}
>
<template x-if="state === AlertState.PENDING">
<div class="alert alert-yellow">
<div class="alert-main">
<strong>{% trans %}Waiting publication{% endtrans %}</strong>
<p>
{% trans trimmed %}
This news isn't published and is visible
only by its author and the communication admins.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
It will stay hidden for other users until it has been published.
{% endtrans %}
</p>
{% if user.has_perm("com.moderate_news") %}
{# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time
(if they do their job and moderated news as soon as they see them),
so it's still reasonable #}
<div
{% if news is integer or news is string %}
x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToPublish()"
{% else %}
x-data="{ nbEvents: {{ news.dates.count() }} }"
{% endif %}
>
<template x-if="nbEvents > 1">
<div>
<br>
<strong>{% trans %}Weekly event{% endtrans %}</strong>
<p x-text="weeklyEventWarningMessage(nbEvents)"></p>
</div>
</template>
</div>
{% endif %}
</div>
{% if user.has_perm("com.moderate_news") %}
<span class="alert-aside" :aria-busy="loading">
<button class="btn btn-green" @click="publishNews()" :disabled="loading">
<i class="fa fa-check"></i> {% trans %}Publish{% endtrans %}
</button>
{% endif %}
{% if user.has_perm("com.delete_news") %}
<button class="btn btn-red" @click="deleteNews()" :disabled="loading">
<i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
</button>
</span>
{% endif %}
</div>
</template>
<template x-if="state === AlertState.PUBLISHED">
<div class="alert alert-green">
{% trans %}News published{% endtrans %}
</div>
</template>
<template x-if="state === AlertState.DELETED">
<div class="alert alert-red">
{% trans %}News deleted{% endtrans %}
</div>
</template>
</div>
{% endmacro %}

View File

@ -27,7 +27,7 @@
</tr>
</thead>
<tbody>
{% for news in weeklies.filter(is_moderated=True) %}
{% for news in weeklies.filter(is_published=True) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -47,7 +47,7 @@
</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
@ -67,7 +67,7 @@
</tr>
</thead>
<tbody>
{% for news in weeklies.filter(is_moderated=False) %}
{% for news in weeklies.filter(is_published=False) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -86,7 +86,7 @@
</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
@ -111,7 +111,7 @@
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=True) %}
{% for news in events.filter(is_published=True) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -124,7 +124,7 @@
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
@ -145,7 +145,7 @@
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=False) %}
{% for news in events.filter(is_published=False) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -157,7 +157,7 @@
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>

View File

@ -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 @@
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
{% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
{% endblock %}
{% block content %}
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
<section id="news_details">
<div class="club_logo">
<img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
</div>
<h4>{{ news.title }}</h4>
<p class="date">
<span>{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</span>
</p>
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}
<p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
{% elif user.is_com_admin %}
<p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
{% endif %}
{% if user.can_edit(news) %}
<p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
{% endif %}
<div x-data="{newsState: AlertState.PENDING}">
{% if not news.is_published %}
{{ news_moderation_alert(news, user, "newsState") }}
{% endif %}
<article id="news_details" x-show="newsState !== AlertState.DELETED">
<div class="club_logo">
<img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
</div>
</div>
</section>
<h4>{{ news.title }}</h4>
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p>
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}
<p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
{% elif user.is_com_admin %}
<p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a></p>
{% endif %}
{% if user.can_edit(news) %}
<p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
{% endif %}
</div>
</div>
</article>
</div>
{% endblock %}

View File

@ -80,9 +80,9 @@
</fieldset>
{% if user.is_root or user.is_com_admin %}
<fieldset>
{{ form.auto_moderate.errors }}
{{ form.auto_moderate }}
{{ form.auto_moderate.label_tag() }}
{{ form.auto_publish.errors }}
{{ form.auto_publish }}
{{ form.auto_publish.label_tag() }}
</fieldset>
{% endif %}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p>

View File

@ -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 %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
{% endblock %}
{% block content %}
<div id="news">
<div id="left_column" class="news_column">
{% 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') %}
<h3>
{% trans %}Events today and the next few days{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
@ -33,57 +33,169 @@
</a>
{% endif %}
{% if user.is_com_admin %}
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">
{% trans %}Administrate news{% endtrans %}
</a>
<br>
{% endif %}
{% if events_dates %}
{% for d in events_dates %}
<div class="news_events_group">
<div class="news_events_group_date">
<div>
<div>{{ d|localtime|date('D') }}</div>
<div class="day">{{ d|localtime|date('d') }}</div>
<div>{{ d|localtime|date('b') }}</div>
{% endif %}
<section id="upcoming-events">
{% if not news_dates %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div>
{% else %}
{% for day, dates_group in news_dates.items() %}
<div class="news_events_group">
<div class="news_events_group_date">
<div>
<div>{{ day|date('D') }}</div>
<div class="day">{{ day|date('d') }}</div>
<div>{{ day|date('b') }}</div>
</div>
</div>
<div class="news_events_group_items">
{% for date in dates_group %}
<article
class="news_event"
{%- if not date.news.is_published -%}
x-data="{newsState: AlertState.PENDING}"
{% else %}
x-data="{newsState: AlertState.DISPLAYED}"
{%- endif -%}
>
{# 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") }}
<div
x-show="newsState !== AlertState.DELETED"
>
<header class="row gap">
{% if date.news.club.logo %}
<img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/>
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
{% endif %}
<div class="header_content">
<h4>
<a href="{{ url('com:news_detail', news_id=date.news_id) }}">
{{ date.news.title }}
</a>
</h4>
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
<div class="news_date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
</div>
</header>
<div class="news_content markdown">
{{ date.news.summary|markdown }}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
<div class="news_events_group_items">
{% 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') %}
<section class="news_event">
<div class="club_logo">
{% if news.club.logo %}
<img src="{{ news.club.logo.url }}" alt="{{ news.club }}" />
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
{% endif %}
{% endfor %}
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
<template x-for="newsList in Object.values(groupedDates())">
<div class="news_events_group">
<div class="news_events_group_date">
<div x-data="{day: newsList[0].start_date}">
<div x-text="day.toLocaleString('{{ get_language() }}', { weekday: 'short' }).substring(0, 3)"></div>
<div
class="day"
x-text="day.toLocaleString('{{ get_language() }}', { day: 'numeric' })"
></div>
<div x-text="day.toLocaleString('{{ get_language() }}', { month: 'short' }).substring(0, 3)"></div>
</div>
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}
<div class="button_bar">
{{ fb_quick(news) }}
{{ tweet_quick(news) }}
</div>
</div>
</section>
{% endfor %}
</div>
<div class="news_events_group_items">
<template x-for="newsDate in newsList" :key="newsDate.id">
<article
class="news_event"
x-data="{ newsState: newsDate.news.is_published ? AlertState.PUBLISHED : AlertState.PENDING }"
>
<template x-if="!newsDate.news.is_published">
{{ news_moderation_alert("newsDate.news.id", user, "newsState") }}
</template>
<div x-show="newsState !== AlertState.DELETED">
<header class="row gap">
<img
:src="newsDate.news.club.logo || '{{ static("com/img/news.png") }}'"
:alt="newsDate.news.club.name"
/>
<div class="header_content">
<h4>
<a :href="newsDate.news.url" x-text="newsDate.news.title"></a>
</h4>
<a :href="newsDate.news.club.url" x-text="newsDate.news.club.name"></a>
<div class="news_date">
<time
:datetime="newsDate.start_date.toISOString()"
x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
></time> -
<time
:datetime="newsDate.end_date.toISOString()"
x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
></time>
</div>
</div>
</header>
{# The API returns a summary in html.
It's generated from our markdown subset, so it should be safe #}
<div class="news_content markdown" x-html="newsDate.news.summary"></div>
</div>
</article>
</template>
</div>
</div>
</template>
<div id="load-more-news-button" :aria-busy="loading">
<button class="btn btn-grey" x-show="!loading && hasNext" @click="loadMore()">
{% trans %}See more{% endtrans %} &nbsp;<i class="fa fa-arrow-down"></i>
</button>
<p x-show="!loading && !hasNext">
<em>
{% trans trimmed %}
It was too short.
You already reached the end of the upcoming events list.
{% endtrans %}
</em>
</p>
</div>
</div>
{% endfor %}
{% else %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div>
{% endif %}
{% endif %}
</section>
<h3>
{% trans %}All coming events{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
<ics-calendar
x-data
x-ref="calendar"
@news-moderated.window="
if ($event.target !== $refs.calendar){
// Avoid triggering a refresh with a dispatch
// from the calendar itself
$refs.calendar.refreshEvents($event);
}
"
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
@calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
locale="{{ get_language() }}"
can_moderate="{{ user.has_perm("com.moderate_news") }}"
can_delete="{{ user.has_perm("com.delete_news") }}"
></ics-calendar>
</div>
<div id="right_column">
@ -110,18 +222,26 @@
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">
{% trans %}Discord AE{% endtrans %}
</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">{% trans %}Dev Team{% endtrans %}</a>
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">
{% trans %}Dev Team{% endtrans %}
</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">
Facebook
</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">
Instagram
</a>
</li>
</ul>
</div>
@ -130,7 +250,7 @@
<div id="birthdays">
<h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content">
{%- if user.was_subscribed -%}
{%- if user.has_perm("core.view_user") -%}
<ul class="birthdays_year">
{%- for year, users in birthdays -%}
<li>
@ -143,8 +263,13 @@
</li>
{%- endfor -%}
</ul>
{%- else -%}
{%- elif not user.was_subscribed -%}
{# The user cannot view birthdays, because he never subscribed #}
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- else -%}
{# There is another reason why user cannot view birthdays (maybe he is banned)
but we cannot know exactly what is this reason #}
<p>{% trans %}You cannot access this content{% endtrans %}</p>
{%- endif -%}
</div>
</div>

View File

@ -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]]

View File

@ -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,
)

View File

@ -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}]

View File

@ -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")

View File

@ -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(

View File

@ -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"},
),
]

View File

@ -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;
},
}));
});
};

View File

@ -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;
}
}
}
/**

View File

@ -39,14 +39,6 @@
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %}
{% macro fb_quick(news) -%}
<a rel="nofollow" target="#" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}" class="fb fa-brands fa-facebook fa-2x"></a>
{%- endmacro %}
{% macro tweet_quick(news) -%}
<a rel="nofollow" target="#" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}" class="twitter fa-brands fa-twitter-square fa-2x"></a>
{%- endmacro %}
{% macro user_mini_profile(user) %}
<div class="user_mini_profile">
<div class="user_mini_profile_infos">

View File

@ -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

View File

@ -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/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
"user/<int:user_id>/pictures/",
RedirectView.as_view(pattern_name="sas:user_pictures", permanent=True),
),
path(
"user/<int:user_id>/godfathers/",

View File

@ -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:

View File

@ -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":

View File

@ -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<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: undefined,
alertMessage: {
content: "",
show: false,
timeout: null,
},
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record<string, BasketItem>,
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();
},
}));
});
$(() => {

View File

@ -27,7 +27,13 @@
{% block content %}
<h4>{{ counter }}</h4>
<div id="bar-ui" x-data="counter">
<div id="bar-ui" x-data="counter({
customerBalance: {{ customer.amount }},
products: products,
customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: '{{ cancel_url }}',
})">
<noscript>
<p class="important">Javascript is required for the counter UI.</p>
</noscript>
@ -255,14 +261,5 @@
{%- endif -%}
{%- endfor -%}
];
window.addEventListener("DOMContentLoaded", () => {
loadCounter({
customerBalance: {{ customer.amount }},
products: products,
customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: "{{ cancel_url }}",
});
});
</script>
{% endblock script %}

View File

@ -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

View File

@ -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"))

View File

@ -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é.

View File

@ -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\.)(?<method>\w+)\(\s*"(?<env_name>\w+)"(\s*(, default=)(?<value>.+))?\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"
```

View File

@ -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/<la_lettre_du_disque>/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/<la_lettre_du_disque>/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/<la_lettre_du_disque>/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/<la_lettre_du_disque>/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.

View File

@ -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
```
</div>
@ -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 <nom de la commande>`
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)
```
</div>
@ -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

View File

@ -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 <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\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"

View File

@ -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 <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\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"

View File

@ -157,6 +157,7 @@ markdown_extensions:
- md_in_html
- pymdownx.details
- pymdownx.inlinehilite
- pymdownx.keys
- pymdownx.superfences:
custom_fences:
- name: mermaid

View File

@ -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())

View File

View File

View File

@ -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"))

View File

@ -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

View File

@ -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;

View File

@ -85,7 +85,7 @@
</div>
</div>
</form>
<table id="dynamic_view">
<table id="uv-list">
<thead>
<tr>
<td>{% trans %}UV{% endtrans %}</td>
@ -102,11 +102,17 @@
{% endif %}
</tr>
</thead>
<tbody id="dynamic_view_content" :aria-busy="loading">
<tbody :aria-busy="loading">
<template x-for="uv in uvs.results" :key="uv.id">
<tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable">
<tr
@click="window.location.href = `/pedagogy/uv/${uv.id}`"
class="clickable"
:class="{closed: uv.semester === 'CLOSED'}"
>
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
<td x-text="uv.title"></td>
<td class="title"
x-text="uv.title + (uv.semester === 'CLOSED' ? ' ({% trans %}closed uv{% endtrans %})' : '')"
></td>
<td x-text="uv.department"></td>
<td x-text="uv.credit_type"></td>
<td><i :class="uv.semester.includes('AUTUMN') && 'fa fa-leaf'"></i></td>

View File

@ -46,12 +46,7 @@
const codeInput = document.querySelector('input[name="code"]')
autofillBtn.addEventListener('click', () => {
const today = new Date()
let year = today.getFullYear()
if (today.getMonth() < 7) { // student year starts in september
year--
}
const url = `/api/uv/${year}/${codeInput.value}`;
const url = `/api/uv/${codeInput.value}`;
deleteQuickNotifs()
$.ajax({
@ -70,7 +65,7 @@
.filter(([elem, _]) => !!elem) // skip non-existing DOM elements
.forEach(([elem, val]) => { // write the value in the form field
if (elem.tagName === 'TEXTAREA') {
// MD editor text input
// MD editor text input
elem.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(val);
} else {
elem.value = val;

View File

@ -1,32 +1,96 @@
"""Set of functions to interact with the UTBM UV api."""
import urllib
from typing import Iterator
import requests
from django.conf import settings
from django.utils.functional import cached_property
from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
def find_uv(lang, year, code) -> UvSchema | None:
"""Find an UV from the UTBM API."""
# query the UV list
base_url = settings.SITH_PEDAGOGY_UTBM_API
uvs_url = f"{base_url}/uvs/{lang}/{year}"
response = urllib.request.urlopen(uvs_url)
uvs: list[UtbmShortUvSchema] = ShortUvList.validate_json(response.read())
class UtbmApiClient(requests.Session):
"""A wrapper around `requests.Session` to perform requests to the UTBM UV API."""
short_uv = next((uv for uv in uvs if uv.code == code), None)
if short_uv is None:
return None
BASE_URL = settings.SITH_PEDAGOGY_UTBM_API
_cache = {"short_uvs": {}}
# get detailed information about the UV
uv_url = f"{base_url}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
response = urllib.request.urlopen(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.read())
return _make_clean_uv(short_uv, full_uv)
@cached_property
def current_year(self) -> int:
"""Fetch from the API the latest existing year"""
url = f"{self.BASE_URL}/guides/fr"
response = self.get(url)
return response.json()[-1]["annee"]
def fetch_short_uvs(
self, lang: str = "fr", year: int | None = None
) -> list[UtbmShortUvSchema]:
"""Get the list of UVs in their short format from the UTBM API"""
if year is None:
year = self.current_year
if lang not in self._cache["short_uvs"]:
self._cache["short_uvs"][lang] = {}
if year not in self._cache["short_uvs"][lang]:
url = f"{self.BASE_URL}/uvs/{lang}/{year}"
response = self.get(url)
uvs = ShortUvList.validate_json(response.content)
self._cache["short_uvs"][lang][year] = uvs
return self._cache["short_uvs"][lang][year]
def fetch_uvs(
self, lang: str = "fr", year: int | None = None
) -> Iterator[UvSchema]:
"""Fetch all UVs from the UTBM API, parsed in a format that we can use.
Warning:
We need infos from the full uv schema, and the UTBM UV API
has no route to get all of them at once.
We must do one request per UV (for a total of around 730 UVs),
which takes a lot of time.
Hopefully, there seems to be no rate-limit, so an error
in the middle of the process isn't likely to occur.
"""
if year is None:
year = self.current_year
shorts_uvs = self.fetch_short_uvs(lang, year)
# When UVs are common to multiple branches (like most HUMA)
# the UTBM API duplicates them for every branch.
# We have no way in our db to link a UV to multiple formations,
# so we just create a single UV, which formation is the one
# of the first UV found in the list.
# For example, if we have CC01 (TC), CC01 (IMSI) and CC01 (EDIM),
# we will only keep CC01 (TC).
unique_short_uvs = {}
for uv in shorts_uvs:
if uv.code not in unique_short_uvs:
unique_short_uvs[uv.code] = uv
for uv in unique_short_uvs.values():
uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{uv.code}/{uv.code_formation}"
response = requests.get(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.content)
yield make_clean_uv(uv, full_uv)
def find_uv(self, lang: str, code: str, year: int | None = None) -> UvSchema | None:
"""Find an UV from the UTBM API."""
# query the UV list
if not year:
year = self.current_year
# the UTBM API has no way to fetch a single short uv,
# and short uvs contain infos that we need and are not
# in the full uv schema, so we must fetch everything.
short_uvs = self.fetch_short_uvs(lang, year)
short_uv = next((uv for uv in short_uvs if uv.code == code), None)
if short_uv is None:
return None
# get detailed information about the UV
uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
response = requests.get(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.content)
return make_clean_uv(short_uv, full_uv)
def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
"""Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short uv schema, some
@ -61,9 +125,9 @@ def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> Uv
semester = "CLOSED"
return UvSchema(
title=full_uv.libelle,
title=full_uv.libelle or "",
code=full_uv.code,
credit_type=short_uv.code_categorie,
credit_type=short_uv.code_categorie or "FREE",
semester=semester,
language=short_uv.code_langue.upper(),
credits=full_uv.credits_ects,
@ -74,8 +138,8 @@ def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> Uv
hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_uv.activites if i.code == "CM"), 0) // 60,
manager=full_uv.respo_automne or full_uv.respo_printemps or "",
objectives=full_uv.objectifs,
program=full_uv.programme,
skills=full_uv.acquisition_competences,
key_concepts=full_uv.acquisition_notions,
objectives=full_uv.objectifs or "",
program=full_uv.programme or "",
skills=full_uv.acquisition_competences or "",
key_concepts=full_uv.acquisition_notions or "",
)

View File

@ -44,6 +44,9 @@ dependencies = [
"django-honeypot<2.0.0,>=1.2.1",
"pydantic-extra-types<3.0.0,>=2.10.1",
"ical<9.0.0,>=8.3.0",
"redis[hiredis]<6.0.0,>=5.2.0",
"environs[django]<15.0.0,>=14.1.0",
"requests>=2.32.3",
]
[project.urls]
@ -53,7 +56,6 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups]
prod = [
"psycopg[c]<4.0.0,>=3.2.3",
"redis[hiredis]<6.0.0,>=5.2.0",
]
dev = [
"django-debug-toolbar<5.0.0,>=4.4.6",

View File

@ -104,7 +104,7 @@ class PicturesController(ControllerBase):
viewed=False,
type="NEW_PICTURES",
defaults={
"url": reverse("core:user_pictures", kwargs={"user_id": u.id})
"url": reverse("sas:user_pictures", kwargs={"user_id": u.id})
},
)

View File

@ -39,6 +39,8 @@ class PictureSchema(ModelSchema):
compressed_url: str
thumb_url: str
album: str
report_url: str
edit_url: str
@staticmethod
def resolve_sas_url(obj: Picture) -> str:
@ -56,6 +58,14 @@ class PictureSchema(ModelSchema):
def resolve_thumb_url(obj: Picture) -> str:
return obj.get_download_thumb_url()
@staticmethod
def resolve_report_url(obj: Picture) -> str:
return reverse("sas:picture_ask_removal", kwargs={"picture_id": obj.id})
@staticmethod
def resolve_edit_url(obj: Picture) -> str:
return reverse("sas:picture_edit", kwargs={"picture_id": obj.id})
class PictureRelationCreationSchema(Schema):
picture: NonNegativeInt

View File

@ -1,59 +0,0 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef AlbumConfig
* @property {number} albumId id of the album to visualize
* @property {number} maxPageSize maximum number of elements to show on a page
**/
/**
* Create a family graph of an user
* @param {AlbumConfig} config
**/
window.loadAlbum = (config) => {
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", () => ({
pictures: {},
page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false,
async init() {
await this.fetchPictures();
this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push;
this.fetchPictures();
});
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) ||
1;
});
},
async fetchPictures() {
this.loading = true;
this.pictures = (
await picturesFetchPictures({
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId,
page: this.page,
// biome-ignore lint/style/useNamingConvention: API is in snake_case
page_size: config.maxPageSize,
},
})
).data;
this.loading = false;
},
nbPages() {
return Math.ceil(this.pictures.count / config.maxPageSize);
},
}));
});
};

View File

@ -0,0 +1,58 @@
import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import {
type PictureSchema,
type PicturesFetchPicturesData,
picturesFetchPictures,
} from "#openapi";
interface AlbumConfig {
albumId: number;
maxPageSize: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", (config: AlbumConfig) => ({
pictures: [] as PictureSchema[],
page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false,
async init() {
await this.fetchPictures();
this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push;
});
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
});
this.config = config;
},
getPage(page: number) {
return this.pictures.slice(
(page - 1) * config.maxPageSize,
config.maxPageSize * page,
);
},
async fetchPictures() {
this.loading = true;
this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId,
} as PicturesFetchPicturesData["query"],
});
this.loading = false;
},
nbPages() {
return Math.ceil(this.pictures.length / config.maxPageSize);
},
}));
});

View File

@ -0,0 +1,46 @@
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import type { PictureSchema } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("pictures_download", () => ({
isDownloading: false,
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = (_total: number): undefined => {
bar.value++;
return undefined;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/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;
},
}));
});

View File

@ -0,0 +1,39 @@
import { paginated } from "#core:utils/api";
import {
type PictureSchema,
type PicturesFetchPicturesData,
picturesFetchPictures,
} from "#openapi";
interface PagePictureConfig {
userId: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", (config: PagePictureConfig) => ({
loading: true,
pictures: [] as PictureSchema[],
albums: {} as Record<string, PictureSchema[]>,
async init() {
this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: from python api
users_identified: [config.userId],
} as PicturesFetchPicturesData["query"],
});
this.albums = this.pictures.reduce(
(acc: Record<string, PictureSchema[]>, picture: PictureSchema) => {
if (!acc[picture.album]) {
acc[picture.album] = [];
}
acc[picture.album].push(picture);
return acc;
},
{},
);
this.loading = false;
},
}));
});

View File

@ -20,8 +20,8 @@ main {
flex-wrap: wrap;
gap: 5px;
> a,
> input {
>a,
>input {
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@ -46,14 +46,14 @@ main {
display: flex;
flex-direction: column;
> .inputs {
>.inputs {
align-items: flex-end;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
> p {
>p {
box-sizing: border-box;
max-width: 300px;
width: 100%;
@ -62,7 +62,7 @@ main {
max-width: 100%;
}
> input {
>input {
box-sizing: border-box;
max-width: 100%;
width: 100%;
@ -72,8 +72,8 @@ main {
}
}
> div > input,
> input {
>div>input,
>input {
box-sizing: border-box;
height: 40px;
width: 100%;
@ -84,12 +84,12 @@ main {
}
}
> div {
>div {
width: 100%;
max-width: 300px;
}
> input[type=submit]:hover {
>input[type=submit]:hover {
background-color: #287fb8;
color: white;
}
@ -100,27 +100,27 @@ main {
.clipboard {
margin-top: 10px;
padding: 10px;
background-color: rgba(0,0,0,.1);
background-color: rgba(0, 0, 0, .1);
border-radius: 10px;
}
.photos,
.albums {
margin: 20px;
min-height: 50px; // To contain the aria-busy loading wheel, even if empty
min-height: 50px; // To contain the aria-busy loading wheel, even if empty
box-sizing: border-box;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 5px;
> div {
>div {
background: rgba(0, 0, 0, .5);
cursor: not-allowed;
}
> div,
> a {
>div,
>a {
box-sizing: border-box;
position: relative;
height: 128px;
@ -138,7 +138,7 @@ main {
background: rgba(0, 0, 0, .5);
}
> input[type=checkbox] {
>input[type=checkbox] {
position: absolute;
top: 0;
right: 0;
@ -149,8 +149,8 @@ main {
cursor: pointer;
}
> .photo,
> .album {
>.photo,
>.album {
box-sizing: border-box;
background-color: #333333;
background-size: contain;
@ -166,25 +166,32 @@ main {
border: 1px solid rgba(0, 0, 0, .3);
>img {
object-position: top bottom;
object-fit: contain;
height: 100%;
width: 100%
}
@media (max-width: 500px) {
width: 100%;
height: 100%;
}
&:hover > .text {
&:hover>.text {
background-color: rgba(0, 0, 0, .5);
}
&:hover > .overlay {
&:hover>.overlay {
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
~ .text {
~.text {
background-color: transparent;
}
}
> .text {
>.text {
position: absolute;
box-sizing: border-box;
top: 0;
@ -201,7 +208,7 @@ main {
color: white;
}
> .overlay {
>.overlay {
position: absolute;
width: 100%;
height: 100%;
@ -227,14 +234,14 @@ main {
}
}
> .album > div {
>.album>div {
background: rgba(0, 0, 0, .5);
background: linear-gradient(0deg, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, 0) 100%);
text-align: left;
word-break: break-word;
}
> .photo > .text {
>.photo>.text {
align-items: center;
padding-bottom: 30px;
}

View File

@ -1,12 +1,14 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import paginate_alpine %}
{% from "sas/macros.jinja" import download_button %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/album.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script type="module" src="{{ static('bundled/sas/album-index.js') }}"></script>
<script type="module" src="{{ static('bundled/sas/album-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/sas/pictures-download-index.ts') }}"></script>
{%- endblock -%}
{% block title %}
@ -27,7 +29,6 @@
{% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="album-navbar">
<h3>{{ album.get_display_name() }}</h3>
@ -63,16 +64,22 @@
<br>
{% endif %}
<div x-data="pictures">
<div x-data="pictures({
albumId: {{ album.id }},
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
})">
{{ download_button(_("Download album")) }}
<h4>{% trans %}Pictures{% endtrans %}</h4>
<div class="photos" :aria-busy="loading">
<template x-for="picture in pictures.results">
<a :href="`/sas/picture/${picture.id}`">
<template x-for="picture in getPage(page)">
<a :href="picture.sas_url">
<div
class="photo"
:class="{not_moderated: !picture.is_moderated}"
:style="`background-image: url(${picture.thumb_url})`"
>
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
@ -112,13 +119,6 @@
{% block script %}
{{ super() }}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadAlbum({
albumId: {{ album.id }},
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
});
});
// Todo: migrate to alpine.js if we have some time
$("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]);
@ -225,6 +225,5 @@
}
});
</script>
{% endblock %}

View File

@ -2,15 +2,19 @@
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% set src = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %}
{% set picture = a.children.filter(is_folder=False).first().as_picture %}
{% set img = picture.get_download_thumb_url() %}
{% set src = picture.name %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% set src = "sas.jpg" %}
{% endif %}
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{{ img }}');"
>
<img src="{{ img }}" alt="{{ src }}" loading="lazy" />
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
@ -30,3 +34,31 @@
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}
{# Helper macro to create a download button for a
record of albums with alpine
This needs to be used inside an alpine environment.
Downloaded pictures will be `pictures` from the
parent data store.
Note:
This requires importing `bundled/sas/pictures-download-index.ts`
Parameters:
name (str): name displayed on the button
#}
{% macro download_button(name) %}
<div x-data="pictures_download">
<div x-show="pictures.length > 0" x-cloak>
<button
:disabled="isDownloading"
class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}"
@click="downloadZip()"
>
<i class="fa fa-download"></i>{{ name }}
</button>
<progress x-ref="progress" x-show="isDownloading"></progress>
</div>
</div>
{% endmacro %}

View File

@ -114,12 +114,12 @@
{% trans %}HD version{% endtrans %}
</a>
<br>
<a class="text danger" :href="`/sas/picture/${currentPicture.id}/report`">
<a class="text danger" :href="currentPicture.report_url">
{% trans %}Ask for removal{% endtrans %}
</a>
</div>
<div class="buttons">
<a class="button" :href="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a class="button" :href="currentPicture.edit_url"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a class="button" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
<a class="button" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
</div>

View File

@ -1,11 +1,13 @@
{% extends "core/base.jinja" %}
{% from "sas/macros.jinja" import download_button %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/album.scss') }}">
{%- endblock -%}
{% block additional_js %}
<script type="module" src="{{ static('bundled/user/pictures-index.js') }}"></script>
<script type="module" src="{{ static('bundled/sas/user/pictures-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/sas/pictures-download-index.ts') }}"></script>
{% endblock %}
{% block title %}
@ -13,33 +15,28 @@
{% endblock %}
{% block content %}
<main x-data="user_pictures">
<main x-data="user_pictures({ userId: {{ object.id }} })">
{% if user.id == object.id %}
<div x-show="pictures.length > 0" x-cloak>
<button
:disabled="isDownloading"
class="btn btn-blue"
@click="downloadZip()"
>
<i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %}
</button>
<progress x-ref="progress" x-show="isDownloading"></progress>
</div>
{{ download_button(_("Download all my pictures")) }}
{% endif %}
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
<section>
<br />
<h4 x-text="album"></h4>
<div class="row">
<h4 x-text="album"></h4>
{% if user.id == object.id %}
&nbsp;{{ download_button("") }}
{% endif %}
</div>
<div class="photos">
<template x-for="picture in pictures">
<a :href="`/sas/picture/${picture.id}`">
<a :href="picture.sas_url">
<div
class="photo"
:class="{not_moderated: !picture.is_moderated}"
:style="`background-image: url(${picture.thumb_url})`"
>
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
@ -56,13 +53,3 @@
<div class="photos" :aria-busy="loading"></div>
</main>
{% endblock content %}
{% block script %}
{{ super() }}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadPicturePage({ userId: {{ object.id }} });
})
</script>
{% endblock script %}

View File

@ -171,3 +171,37 @@ class TestSasModeration(TestCase):
"Vous avez déjà déposé une demande de retrait pour cette photo.</li></ul>",
res.content.decode(),
)
@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(
"sas: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(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status

View File

@ -24,6 +24,7 @@ from sas.views import (
PictureEditView,
PictureView,
SASMainView,
UserPicturesView,
send_album,
send_compressed,
send_pict,
@ -55,4 +56,7 @@ urlpatterns = [
name="download_compressed",
),
path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"),
path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
),
]

View File

@ -26,6 +26,7 @@ from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User
from core.views.files import FileView, send_file
from core.views.user import UserTabsMixin
from sas.forms import (
AlbumEditForm,
PictureEditForm,
@ -193,6 +194,16 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
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 = "sas/user_pictures.jinja"
current_tab = "pictures"
# Admin views

View File

@ -34,7 +34,6 @@ https://docs.djangoproject.com/en/1.8/ref/settings/
"""
import binascii
import logging
import os
import sys
from datetime import timedelta
@ -43,25 +42,33 @@ from pathlib import Path
import sentry_sdk
from dateutil.relativedelta import relativedelta
from django.utils.translation import gettext_lazy as _
from environs import Env
from sentry_sdk.integrations.django import DjangoIntegration
from .honeypot import custom_honeypot_error
BASE_DIR = Path(__file__).parent.parent.resolve()
env = Env()
env.read_env()
os.environ["HTTPS"] = "off"
BASE_DIR = Path(__file__).parent.parent.resolve()
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2"
SECRET_KEY = env.str("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
DEBUG = env.bool("SITH_DEBUG", default=False)
TESTING = "pytest" in sys.modules
INTERNAL_IPS = ["127.0.0.1"]
# force csrf tokens and cookies to be secure when in https
CSRF_COOKIE_SECURE = env.bool("HTTPS", default=True)
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
SESSION_COOKIE_SECURE = env.bool("HTTPS", default=True)
X_FRAME_OPTIONS = "SAMEORIGIN"
ALLOWED_HOSTS = ["*"]
# Application definition
@ -164,6 +171,7 @@ TEMPLATES = [
"timezone": "django.utils.timezone",
"get_sith": "com.views.sith",
"get_language": "django.utils.translation.get_language",
"timedelta": "datetime.timedelta",
},
"bytecode_cache": {
"name": "default",
@ -208,12 +216,12 @@ WSGI_APPLICATION = "sith.wsgi.application"
# Database
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
},
"default": env.dj_db_url("DATABASE_URL", conn_max_age=None, conn_health_checks=True)
}
if "CACHE_URL" in os.environ:
CACHES = {"default": env.dj_cache_url("CACHE_URL")}
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
# Logging
@ -265,13 +273,13 @@ PHONENUMBER_DEFAULT_REGION = "FR"
# Medias
MEDIA_URL = "/data/"
MEDIA_ROOT = BASE_DIR / "data"
MEDIA_ROOT = env.path("MEDIA_ROOT", default=BASE_DIR / "data")
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static"
STATIC_ROOT = env.path("STATIC_ROOT", default=BASE_DIR / "static")
# Static files finders which allow to see static folder in all apps
STATICFILES_FINDERS = [
@ -295,24 +303,28 @@ AUTHENTICATION_BACKENDS = ["core.auth.backends.SithModelBackend"]
LOGIN_URL = "/login/"
LOGOUT_URL = "/logout/"
LOGIN_REDIRECT_URL = "/"
DEFAULT_FROM_EMAIL = "bibou@git.an"
SITH_COM_EMAIL = "bibou_com@git.an"
DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL", default="bibou@git.an")
SITH_COM_EMAIL = env.str("SITH_COM_EMAIL", default="bibou_com@git.an")
# Those values are to be changed in production to be more effective
HONEYPOT_FIELD_NAME = "body2"
HONEYPOT_VALUE = "content"
HONEYPOT_FIELD_NAME = env.str("HONEYPOT_FIELD_NAME", default="body2")
HONEYPOT_VALUE = env.str("HONEYPOT_VALUE", default="content")
HONEYPOT_RESPONDER = custom_honeypot_error # Make honeypot errors less suspicious
HONEYPOT_FIELD_NAME_FORUM = "message2" # Only used on forum
HONEYPOT_FIELD_NAME_FORUM = env.str(
"HONEYPOT_FIELD_NAME_FORUM", default="message2"
) # Only used on forum
# Email
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
EMAIL_HOST = "localhost"
EMAIL_PORT = 25
EMAIL_BACKEND = env.str(
"EMAIL_BACKEND", default="django.core.mail.backends.dummy.EmailBackend"
)
EMAIL_HOST = env.str("EMAIL_HOST", default="localhost")
EMAIL_PORT = env.int("EMAIL_PORT", default=25)
# Below this line, only Sith-specific variables are defined
SITH_URL = "my.url.git.an"
SITH_NAME = "Sith website"
SITH_URL = env.str("SITH_URL", default="127.0.0.1:8000")
SITH_NAME = env.str("SITH_NAME", default="AE UTBM")
SITH_TWITTER = "@ae_utbm"
# Enable experimental features
@ -321,7 +333,7 @@ SITH_ENABLE_GALAXY = False
# AE configuration
# TODO: keep only that first setting, with the ID, and do the same for the other clubs
SITH_MAIN_CLUB_ID = 1
SITH_MAIN_CLUB_ID = env.int("SITH_MAIN_CLUB_ID", default=1)
SITH_MAIN_CLUB = {
"name": "AE",
"unix_name": "ae",
@ -356,26 +368,28 @@ SITH_SCHOOL_START_YEAR = 1999
# id of the Root account
SITH_ROOT_USER_ID = 0
SITH_GROUP_ROOT_ID = 1
SITH_GROUP_PUBLIC_ID = 2
SITH_GROUP_SUBSCRIBERS_ID = 3
SITH_GROUP_OLD_SUBSCRIBERS_ID = 4
SITH_GROUP_ACCOUNTING_ADMIN_ID = 5
SITH_GROUP_COM_ADMIN_ID = 6
SITH_GROUP_COUNTER_ADMIN_ID = 7
SITH_GROUP_SAS_ADMIN_ID = 8
SITH_GROUP_FORUM_ADMIN_ID = 9
SITH_GROUP_PEDAGOGY_ADMIN_ID = 10
SITH_GROUP_ROOT_ID = env.int("SITH_GROUP_ROOT_ID", default=1)
SITH_GROUP_PUBLIC_ID = env.int("SITH_GROUP_PUBLIC_ID", default=2)
SITH_GROUP_SUBSCRIBERS_ID = env.int("SITH_GROUP_SUBSCRIBERS_ID", default=3)
SITH_GROUP_OLD_SUBSCRIBERS_ID = env.int("SITH_GROUP_OLD_SUBSCRIBERS_ID", default=4)
SITH_GROUP_ACCOUNTING_ADMIN_ID = env.int("SITH_GROUP_ACCOUNTING_ADMIN_ID", default=5)
SITH_GROUP_COM_ADMIN_ID = env.int("SITH_GROUP_COM_ADMIN_ID", default=6)
SITH_GROUP_COUNTER_ADMIN_ID = env.int("SITH_GROUP_COUNTER_ADMIN_ID", default=7)
SITH_GROUP_SAS_ADMIN_ID = env.int("SITH_GROUP_SAS_ADMIN_ID", default=8)
SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9)
SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10)
SITH_GROUP_BANNED_ALCOHOL_ID = 11
SITH_GROUP_BANNED_COUNTER_ID = 12
SITH_GROUP_BANNED_SUBSCRIPTION_ID = 13
SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11)
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12)
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13
)
SITH_CLUB_REFOUND_ID = 89
SITH_COUNTER_REFOUND_ID = 38
SITH_PRODUCT_REFOUND_ID = 5
SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89)
SITH_COUNTER_REFOUND_ID = env.int("SITH_COUNTER_REFOUND_ID", default=38)
SITH_PRODUCT_REFOUND_ID = env.int("SITH_PRODUCT_REFOUND_ID", default=5)
SITH_COUNTER_ACCOUNT_DUMP_ID = 39
SITH_COUNTER_ACCOUNT_DUMP_ID = env.int("SITH_COUNTER_ACCOUNT_DUMP_ID", default=39)
# Pages
SITH_CORE_PAGE_SYNTAX = "Aide_sur_la_syntaxe"
@ -385,7 +399,7 @@ SITH_CORE_PAGE_SYNTAX = "Aide_sur_la_syntaxe"
SITH_FORUM_PAGE_LENGTH = 30
# SAS variables
SITH_SAS_ROOT_DIR_ID = 4
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60
SITH_BOARD_SUFFIX = "-bureau"
@ -492,9 +506,9 @@ SITH_LOG_OPERATION_TYPE = [
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide"
SITH_ECOCUP_CONS = 1152
SITH_ECOCUP_CONS = env.int("SITH_ECOCUP_CONS", default=1151)
SITH_ECOCUP_DECO = 1151
SITH_ECOCUP_DECO = env.int("SITH_ECOCUP_DECO", default=1152)
# The limit is the maximum difference between cons and deco possible for a customer
SITH_ECOCUP_LIMIT = 3
@ -509,13 +523,19 @@ SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
# Defines which product type is the refilling type,
# and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3
SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int(
"SITH_COUNTER_PRODUCTTYPE_REFILLING", default=3
)
# Defines which product is the one year subscription
# and which one is the six month subscription
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = 1
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = 2
SITH_PRODUCTTYPE_SUBSCRIPTION = 2
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = env.int(
"SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER", default=1
)
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = env.int(
"SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS", default=2
)
SITH_PRODUCTTYPE_SUBSCRIPTION = env.int("SITH_PRODUCTTYPE_SUBSCRIPTION", default=2)
# Number of weeks before the end of a subscription when the subscriber can resubscribe
SITH_SUBSCRIPTION_END = 10
@ -624,21 +644,29 @@ SITH_BARMAN_TIMEOUT = 30
SITH_LAST_OPERATIONS_LIMIT = 10
# ET variables
SITH_EBOUTIC_CB_ENABLED = True
SITH_EBOUTIC_ET_URL = (
"https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
SITH_EBOUTIC_ET_URL = env.str(
"SITH_EBOUTIC_ET_URL",
default="https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi",
)
SITH_EBOUTIC_PBX_SITE = "1999888"
SITH_EBOUTIC_PBX_RANG = "32"
SITH_EBOUTIC_PBX_IDENTIFIANT = "2"
SITH_EBOUTIC_PBX_SITE = env.str("SITH_EBOUTIC_PBX_SITE", default="1999888")
SITH_EBOUTIC_PBX_RANG = env.str("SITH_EBOUTIC_PBX_RANG", default="32")
SITH_EBOUTIC_PBX_IDENTIFIANT = env.str("SITH_EBOUTIC_PBX_IDENTIFIANT", default="2")
SITH_EBOUTIC_HMAC_KEY = binascii.unhexlify(
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
env.str(
"SITH_EBOUTIC_HMAC_KEY",
default=(
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
),
)
)
SITH_EBOUTIC_PUB_KEY = ""
with open(os.path.join(os.path.dirname(__file__), "et_keys/pubkey.pem")) as f:
with open(
env.path("SITH_EBOUTIC_PUB_KEY_PATH", default=BASE_DIR / "sith/et_keys/pubkey.pem")
) as f:
SITH_EBOUTIC_PUB_KEY = f.read()
# Launderette variables
@ -680,24 +708,17 @@ SITH_QUICK_NOTIF = {
# Mailing related settings
SITH_MAILING_DOMAIN = "utbm.fr"
SITH_MAILING_FETCH_KEY = "IloveMails"
SITH_MAILING_FETCH_KEY = env.str("SITH_MAILING_FETCH_KEY", default="ILoveMails")
SITH_GIFT_LIST = [("AE Tee-shirt", _("AE tee-shirt"))]
SENTRY_DSN = ""
SENTRY_ENV = "production"
SENTRY_DSN = env.str("SENTRY_DSN", default=None)
SENTRY_ENV = env.str("SENTRY_ENV", default="production")
TOXIC_DOMAINS_PROVIDERS = [
"https://www.stopforumspam.com/downloads/toxic_domains_whole.txt",
]
try:
from .settings_custom import * # noqa F403 (this star-import is actually useful)
logging.getLogger("django").info("Custom settings imported")
except ImportError:
logging.getLogger("django").warning("Custom settings failed")
if DEBUG:
INSTALLED_APPS += ("debug_toolbar",)
MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE)

View File

@ -81,7 +81,7 @@ def sentry_debug(request):
The error will be displayed on Sentry
inside the "development" environment
NOTE : you need to specify the SENTRY_DSN setting in settings_custom.py
NOTE : you need to specify the SENTRY_DSN setting in .env
"""
if settings.SENTRY_ENV != "development" or not settings.SENTRY_DSN:
raise Http404

View File

@ -100,6 +100,13 @@ class SubscriptionNewUserForm(SubscriptionForm):
email=self.cleaned_data.get("email"),
date_of_birth=self.cleaned_data.get("date_of_birth"),
)
if self.cleaned_data.get("subscription_type") in [
"un-semestre",
"deux-semestres",
"cursus-tronc-commun",
"cursus-branche",
]:
member.role = "STUDENT"
member.generate_username()
member.set_password(secrets.token_urlsafe(nbytes=10))
self.instance.member = member

View File

@ -89,6 +89,28 @@ def test_form_new_user(settings: SettingsWrapper):
form.save()
@pytest.mark.django_db
@pytest.mark.parametrize(
"subscription_type",
["un-semestre", "deux-semestres", "cursus-tronc-commun", "cursus-branche"],
)
def test_form_set_new_user_as_student(settings: SettingsWrapper, subscription_type):
"""Test that new users have the student role by default."""
data = {
"first_name": "John",
"last_name": "Doe",
"email": "jdoe@utbm.fr",
"date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": subscription_type,
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionNewUserForm(data)
assert form.is_valid()
form.clean()
assert form.instance.member.role == "STUDENT"
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_factory", "status_code"),

View File

@ -12,6 +12,7 @@
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["jquery", "alpinejs"],
"lib": ["es7"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/index.ts"],
"#core:*": ["./core/static/bundled/*"],

88
uv.lock generated
View File

@ -155,7 +155,7 @@ name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [
@ -276,6 +276,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 },
]
[[package]]
name = "dj-database-url"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/9f/fc9905758256af4f68a55da94ab78a13e7775074edfdcaddd757d4921686/dj_database_url-2.3.0.tar.gz", hash = "sha256:ae52e8e634186b57e5a45e445da5dc407a819c2ceed8a53d1fac004cc5288787", size = 10980 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/91/641a4e5c8903ed59f6cbcce571003bba9c5d2f731759c31db0ba83bb0bdb/dj_database_url-2.3.0-py3-none-any.whl", hash = "sha256:bb0d414ba0ac5cd62773ec7f86f8cc378a9dbb00a80884c2fc08cc570452521e", size = 7793 },
]
[[package]]
name = "dj-email-url"
version = "1.0.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/ef/8eb478accd9b0369d46a98d1b43027ee0c254096149265c78e6b2e2fa3b0/dj-email-url-1.0.6.tar.gz", hash = "sha256:55ffe3329e48f54f8a75aa36ece08f365e09d61f8a209773ef09a1d4760e699a", size = 15590 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/f9/fcb9745099d821f9a26092d3d6f4df8f10049885045c3a93ff726d2e40a6/dj_email_url-1.0.6-py2.py3-none-any.whl", hash = "sha256:cbd08327fbb08b104eac160fb4703f375532e4c0243eb230f5b960daee7a96db", size = 6296 },
]
[[package]]
name = "django"
version = "4.2.17"
@ -290,6 +312,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/85/457360cb3de496382e35db4c2af054066df5c40e26df31400d0109a0500c/Django-4.2.17-py3-none-any.whl", hash = "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", size = 7993390 },
]
[[package]]
name = "django-cache-url"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/28/d420aaa89028d2ec0cf17c1510d06ff6a8ed0bf1abfb7f33c999e1c5befa/django-cache-url-3.4.5.tar.gz", hash = "sha256:eb9fb194717524348c95cad9905b70b647452741c1d9e481fac6d2125f0ad917", size = 7230 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/90/01755e4a42558b763f7021e9369aa6aa94c2ede7313deed56cb7483834ab/django_cache_url-3.4.5-py2.py3-none-any.whl", hash = "sha256:5f350759978483ab85dc0e3e17b3d53eed3394a28148f6bf0f53d11d0feb5b3c", size = 4760 },
]
[[package]]
name = "django-countries"
version = "7.6.1"
@ -439,6 +470,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/69/e391bd51bc08ed9141ecd899a0ddb61ab6465309f1eb470905c0c8868081/docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc", size = 570472 },
]
[[package]]
name = "environs"
version = "14.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "marshmallow" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/8f/952bd034eac79c8b68b6c770cb78c2bdcb3140d31ff224847f3520077d75/environs-14.1.0.tar.gz", hash = "sha256:a5f2afe9d5a21b468e74a3cceacf5d2371fd67dbb9a7e54fe62290c75a09cdfa", size = 30985 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/ad/57cfa3e8a006df88e723524127dbab2971a4877c97e7bad070257e15cb6c/environs-14.1.0-py3-none-any.whl", hash = "sha256:a7edda1668ddf1fbfcb7662bdc242dac25648eff2c7fdbaa5d959693afed7a3e", size = 15332 },
]
[package.optional-dependencies]
django = [
{ name = "dj-database-url" },
{ name = "dj-email-url" },
{ name = "django-cache-url" },
]
[[package]]
name = "executing"
version = "2.1.0"
@ -708,6 +759,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "marshmallow"
version = "3.25.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/5c/cbfa41491d6c83b36471f2a2f75602349d20a8f88afd94f83c1e68bbc298/marshmallow-3.25.0.tar.gz", hash = "sha256:5ba94a4eb68894ad6761a505eb225daf7e5cb7b4c32af62d4a45e9d42665bc31", size = 176751 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/26/b347619b719d4c048e038929769f8f6b28c6d930149b40d950bbdde31d48/marshmallow-3.25.0-py3-none-any.whl", hash = "sha256:50894cd57c6b097a6c6ed2bf216af47d10146990a54db52d03e32edb0448c905", size = 49480 },
]
[[package]]
name = "matplotlib-inline"
version = "0.1.7"
@ -744,7 +807,7 @@ version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "platform_system == 'Windows'" },
{ name = "ghp-import" },
{ name = "jinja2" },
{ name = "markdown" },
@ -1229,6 +1292,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@ -1428,6 +1500,7 @@ dependencies = [
{ name = "django-ordered-model" },
{ name = "django-phonenumber-field" },
{ name = "django-simple-captcha" },
{ name = "environs", extra = ["django"] },
{ name = "ical" },
{ name = "jinja2" },
{ name = "libsass" },
@ -1436,7 +1509,9 @@ dependencies = [
{ name = "pillow" },
{ name = "pydantic-extra-types" },
{ name = "python-dateutil" },
{ name = "redis", extra = ["hiredis"] },
{ name = "reportlab" },
{ name = "requests" },
{ name = "sentry-sdk" },
{ name = "sphinx" },
{ name = "tomli" },
@ -1462,7 +1537,6 @@ docs = [
]
prod = [
{ name = "psycopg", extra = ["c"] },
{ name = "redis", extra = ["hiredis"] },
]
tests = [
{ name = "freezegun" },
@ -1486,6 +1560,7 @@ requires-dist = [
{ name = "django-ordered-model", specifier = ">=3.7.4,<4.0.0" },
{ name = "django-phonenumber-field", specifier = ">=8.0.0,<9.0.0" },
{ name = "django-simple-captcha", specifier = ">=0.6.0,<1.0.0" },
{ name = "environs", extras = ["django"], specifier = ">=14.1.0,<15.0.0" },
{ name = "ical", specifier = ">=8.3.0,<9.0.0" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" },
@ -1494,7 +1569,9 @@ requires-dist = [
{ name = "pillow", specifier = ">=11.0.0,<12.0.0" },
{ name = "pydantic-extra-types", specifier = ">=2.10.1,<3.0.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2.0,<6.0.0" },
{ name = "reportlab", specifier = ">=4.2.5,<5.0.0" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sentry-sdk", specifier = ">=2.19.2,<3.0.0" },
{ name = "sphinx", specifier = ">=5,<6" },
{ name = "tomli", specifier = ">=2.2.1,<3.0.0" },
@ -1518,10 +1595,7 @@ docs = [
{ name = "mkdocstrings", specifier = ">=0.27.0,<1.0.0" },
{ name = "mkdocstrings-python", specifier = ">=1.12.2,<2.0.0" },
]
prod = [
{ name = "psycopg", extras = ["c"], specifier = ">=3.2.3,<4.0.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2.0,<6.0.0" },
]
prod = [{ name = "psycopg", extras = ["c"], specifier = ">=3.2.3,<4.0.0" }]
tests = [
{ name = "freezegun", specifier = ">=1.5.1,<2.0.0" },
{ name = "model-bakery", specifier = ">=1.20.0,<2.0.0" },