{{ news.title }}
++ {{ date.start_date|localtime|date(DATETIME_FORMAT) }} + {{ date.start_date|localtime|time(DATETIME_FORMAT) }} - + {{ date.end_date|localtime|date(DATETIME_FORMAT) }} + {{ date.end_date|localtime|time(DATETIME_FORMAT) }} +
++
diff --git a/com/api.py b/com/api.py index e46daea9b..4a4d0e172 100644 --- a/com/api.py +++ b/com/api.py @@ -5,6 +5,8 @@ from ninja_extra import ControllerBase, api_controller, route from com.calendar import IcsCalendar +from com.models import News +from core.auth.api_permissions import HasPerm from core.views.files import send_raw_file @@ -17,7 +19,7 @@ def calendar_external(self): """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 +32,27 @@ def calendar_external(self): @route.get("/internal.ics", url_name="calendar_internal") def calendar_internal(self): return send_raw_file(IcsCalendar.get_internal()) + + +@api_controller("/news") +class NewsController(ControllerBase): + @route.patch( + "/{news_id}/moderate", + permissions=[HasPerm("com.moderate_news")], + url_name="moderate_news", + ) + def moderate_news(self, news_id: int): + news = self.get_object_or_exception(News, id=news_id) + if not news.is_moderated: + news.is_moderated = True + news.moderator = self.context.request.user + news.save() + + @route.delete( + "/{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() diff --git a/com/models.py b/com/models.py index 1219410aa..229d83c8f 100644 --- a/com/models.py +++ b/com/models.py @@ -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_moderated=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") diff --git a/com/static/bundled/com/components/moderation-alert-index.ts b/com/static/bundled/com/components/moderation-alert-index.ts new file mode 100644 index 000000000..e11290ada --- /dev/null +++ b/com/static/bundled/com/components/moderation-alert-index.ts @@ -0,0 +1,38 @@ +import { exportToHtml } from "#core:utils/globals"; +import { newsDeleteNews, newsModerateNews } 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 + MODERATED: 2, + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + DELETED: 3, +}; +exportToHtml("AlertState", AlertState); + +document.addEventListener("alpine:init", () => { + Alpine.data("moderationAlert", (newsId: number) => ({ + state: AlertState.PENDING, + newsId: newsId as number, + loading: false, + + async moderateNews() { + this.loading = true; + // biome-ignore lint/style/useNamingConvention: api is snake case + await newsModerateNews({ path: { news_id: this.newsId } }); + this.state = AlertState.MODERATED; + 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.loading = false; + }, + })); +}); diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index d073c4ac4..0df403b44 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -171,54 +171,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; } - } - - .news_date { - font-size: 100%; - } - - .news_content { - clear: left; - - .button_bar { - text-align: right; - - .fb { - color: $faceblue; - } - - .twitter { - color: $twitblue; + .header_content { + display: flex; + flex-direction: column; + justify-content: center; + gap: .2rem; + + h4 { + margin-top: 0; + text-transform: uppercase; } } } @@ -228,70 +198,6 @@ /* END EVENTS TODAY AND NEXT FEW DAYS */ - /* COMING SOON */ - .news_coming_soon { - display: list-item; - list-style-type: square; - list-style-position: inside; - margin-left: 1em; - padding-left: 0; - - a { - font-weight: bold; - text-transform: uppercase; - } - - .news_date { - font-size: 0.9em; - } - } - - /* END COMING SOON */ - - /* NOTICES */ - .news_notice { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - box-shadow: $shadow-color 0 0 2px; - border-radius: 18px 5px 18px 5px; - - h4 { - margin: 0; - } - - .news_content { - margin-left: 1em; - } - } - - /* END NOTICES */ - - /* CALLS */ - .news_call { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - border: 1px solid grey; - box-shadow: $shadow-color 1px 1px 1px; - - h4 { - margin: 0; - } - - .news_date { - font-size: 0.9em; - } - - .news_content { - margin-left: 1em; - } - } - - /* END CALLS */ - .news_empty { margin-left: 1em; } diff --git a/com/templates/com/macros.jinja b/com/templates/com/macros.jinja new file mode 100644 index 000000000..46f4b7c4c --- /dev/null +++ b/com/templates/com/macros.jinja @@ -0,0 +1,91 @@ +{% macro news_moderation_alert(news, user, alpineState = None) %} + {# An alert to display on top of non moderated news, + with actions to either moderate 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` + + Example : + ```jinja +
+ {% trans trimmed %} + This news isn't moderated and is visible + only by its author and the communication admins. + {% endtrans %} +
++ {% trans trimmed %} + It will stay hidden for other users until it has been moderated. + {% endtrans %} +
+ {% 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 #} + {% set nb_event=news.dates.count() %} + {% if nb_event > 1 %} ++ {% trans trimmed nb=nb_event %} + This event will take place every week for {{ nb }} weeks. + If you moderate or delete this event, + it will also be moderated (or deleted) for the following weeks. + {% endtrans %} +
+ {% endif %} + {% endif %} +{% trans %}Back to news{% endtrans %}
-- {{ date.start_date|localtime|date(DATETIME_FORMAT) }} - {{ date.start_date|localtime|time(DATETIME_FORMAT) }} - - {{ date.end_date|localtime|date(DATETIME_FORMAT) }} - {{ date.end_date|localtime|time(DATETIME_FORMAT) }} -
-{% trans %}You need to subscribe to access this content{% endtrans %}
+ {%- else -%} + {# There is another reason why user cannot view birthdays (maybe he is banned) + but we cannot know exactly what is this reason #} +{% trans %}You cannot access this content{% endtrans %}
{%- endif -%}