mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-31 00:53:08 +00:00 
			
		
		
		
	Merge pull request #1024 from ae-utbm/news-list
Allow displaying more news
This commit is contained in:
		| @@ -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() | ||||
|   | ||||
							
								
								
									
										30
									
								
								com/api.py
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								com/api.py
									
									
									
									
									
								
							| @@ -1,11 +1,16 @@ | ||||
| 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 ninja import Query | ||||
| from ninja_extra import ControllerBase, api_controller, paginate, route | ||||
| from ninja_extra.pagination import PageNumberPaginationExtra | ||||
| from ninja_extra.schemas import PaginatedResponseSchema | ||||
|  | ||||
| from com.calendar import IcsCalendar | ||||
| from com.models import News | ||||
| 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 | ||||
|  | ||||
| @@ -37,7 +42,7 @@ class CalendarController(ControllerBase): | ||||
| @api_controller("/news") | ||||
| class NewsController(ControllerBase): | ||||
|     @route.patch( | ||||
|         "/{news_id}/moderate", | ||||
|         "/{int:news_id}/moderate", | ||||
|         permissions=[HasPerm("com.moderate_news")], | ||||
|         url_name="moderate_news", | ||||
|     ) | ||||
| @@ -49,10 +54,27 @@ class NewsController(ControllerBase): | ||||
|             news.save() | ||||
|  | ||||
|     @route.delete( | ||||
|         "/{news_id}", | ||||
|         "/{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") | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										58
									
								
								com/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								com/schemas.py
									
									
									
									
									
										Normal 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_moderated: bool | None = Field(None, q="news__is_moderated") | ||||
|     title: str | None = Field(None, q="news__title__icontains") | ||||
|  | ||||
|  | ||||
| class NewsSchema(ModelSchema): | ||||
|     class Meta: | ||||
|         model = News | ||||
|         fields = ["id", "title", "summary", "is_moderated"] | ||||
|  | ||||
|     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 | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { exportToHtml } from "#core:utils/globals"; | ||||
| import { newsDeleteNews, newsModerateNews } from "#openapi"; | ||||
| import { newsDeleteNews, newsFetchNewsDates, newsModerateNews } from "#openapi"; | ||||
|  | ||||
| // This will be used in jinja templates, | ||||
| // so we cannot use real enums as those are purely an abstraction of Typescript | ||||
| @@ -24,6 +24,7 @@ document.addEventListener("alpine:init", () => { | ||||
|       // biome-ignore lint/style/useNamingConvention: api is snake case | ||||
|       await newsModerateNews({ path: { news_id: this.newsId } }); | ||||
|       this.state = AlertState.MODERATED; | ||||
|       this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state }); | ||||
|       this.loading = false; | ||||
|     }, | ||||
|  | ||||
| @@ -32,7 +33,47 @@ document.addEventListener("alpine:init", () => { | ||||
|       // 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 nbToModerate(): 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 moderate or delete this event, " + | ||||
|             "it will also be moderated (or deleted) for the following weeks.", | ||||
|         ), | ||||
|         [nbEvents], | ||||
|       ); | ||||
|     }, | ||||
|   })); | ||||
| }); | ||||
|   | ||||
| @@ -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; | ||||
|           }, | ||||
|           {}, | ||||
|         ); | ||||
|     }, | ||||
|   })); | ||||
| }); | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -6,15 +6,41 @@ | ||||
|     the given `alpineState` variable. | ||||
|     This state is a `AlertState`, as defined in `moderation-alert-index.ts` | ||||
|  | ||||
|     Example : | ||||
|     This comes in three flavours : | ||||
|     - You can pass the `News` object itself to the macro. | ||||
|       In this case, if `request.user` can moderate 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: The `News` object to which this alert is related | ||||
|         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 | ||||
|  | ||||
| @@ -23,7 +49,13 @@ | ||||
|         in your template. | ||||
|     #} | ||||
|   <div | ||||
|     x-data="moderationAlert({{ news.id }})" | ||||
|     {% 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-modelable="{{ alpineState }}" | ||||
|       x-model="state" | ||||
| @@ -49,18 +81,22 @@ | ||||
|             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 %} | ||||
|               <br> | ||||
|               <strong>{% trans %}Weekly event{% endtrans %}</strong> | ||||
|               <p> | ||||
|                 {% 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 %} | ||||
|               </p> | ||||
|             {% endif %} | ||||
|             <div | ||||
|               {% if news is integer or news is string %} | ||||
|                 x-data="{ nbEvents: 0 }" | ||||
|                 x-init="nbEvents = await nbToModerate()" | ||||
|               {% 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") %} | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
| {% 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 %} | ||||
| @@ -37,69 +38,142 @@ | ||||
|         </a> | ||||
|         <br> | ||||
|       {% endif %} | ||||
|       {% for day, dates_group in news_dates %} | ||||
|         <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> | ||||
|       <section id="upcoming-events"> | ||||
|         {% if not news_dates %} | ||||
|           <div class="news_empty"> | ||||
|             <em>{% trans %}Nothing to come...{% endtrans %}</em> | ||||
|           </div> | ||||
|           <div class="news_events_group_items"> | ||||
|             {% for date in dates_group %} | ||||
|               <article | ||||
|                 class="news_event" | ||||
|                 {%- if not date.news.is_moderated -%} | ||||
|                   x-data="{newsState: AlertState.PENDING}" | ||||
|                 {%- endif -%} | ||||
|               > | ||||
|                 {% if not date.news.is_moderated %} | ||||
|                   {# if a non moderated news is in the object list, | ||||
|                   the logged user is either an admin or the news author #} | ||||
|                   {{ news_moderation_alert(date.news, user, "newsState") }} | ||||
|                 {% endif %} | ||||
|                 <div | ||||
|                   {% if not date.news.is_moderated -%} | ||||
|                     x-show="newsState !== AlertState.DELETED" | ||||
|                   {%- endif -%} | ||||
|                 > | ||||
|                   <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 }}"/> | ||||
|         {% else %} | ||||
|           {% for day, dates_group in news_dates %} | ||||
|             <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_moderated -%} | ||||
|                       x-data="{newsState: AlertState.PENDING}" | ||||
|                     {%- endif -%} | ||||
|                   > | ||||
|                     {% if not date.news.is_moderated %} | ||||
|                       {# if a non moderated news is in the object list, | ||||
|                       the logged user is either an admin or the news author #} | ||||
|                       {{ news_moderation_alert(date.news, user, "newsState") }} | ||||
|                     {% 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|time(DATETIME_FORMAT) }} | ||||
|                         </time> - | ||||
|                         <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}"> | ||||
|                           {{ date.end_date|localtime|time(DATETIME_FORMAT) }} | ||||
|                         </time> | ||||
|                     <div | ||||
|                       {% if not date.news.is_moderated -%} | ||||
|                         x-show="newsState !== AlertState.DELETED" | ||||
|                       {%- endif -%} | ||||
|                     > | ||||
|                       <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|time(DATETIME_FORMAT) }} | ||||
|                             </time> - | ||||
|                             <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}"> | ||||
|                               {{ date.end_date|localtime|time(DATETIME_FORMAT) }} | ||||
|                             </time> | ||||
|                           </div> | ||||
|                         </div> | ||||
|                       </header> | ||||
|                       <div class="news_content markdown"> | ||||
|                         {{ date.news.summary|markdown }} | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </header> | ||||
|                   <div class="news_content markdown"> | ||||
|                     {{ date.news.summary|markdown }} | ||||
|                   </article> | ||||
|                 {% endfor %} | ||||
|               </div> | ||||
|             </div> | ||||
|           {% 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> | ||||
|                 </div> | ||||
|               </article> | ||||
|             {% endfor %} | ||||
|                 <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_moderated ? AlertState.MODERATED : AlertState.PENDING }" | ||||
|                     > | ||||
|                       <template x-if="!newsDate.news.is_moderated"> | ||||
|                         {{ 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 %}  <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> | ||||
|         </div> | ||||
|       {% else %} | ||||
|         <div class="news_empty"> | ||||
|           <em>{% trans %}Nothing to come...{% endtrans %}</em> | ||||
|         </div> | ||||
|       {% endfor %} | ||||
|         {% endif %} | ||||
|       </section> | ||||
|  | ||||
|       <h3> | ||||
|         {% trans %}All coming events{% endtrans %} | ||||
|   | ||||
| @@ -3,18 +3,22 @@ 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 model_bakery import baker | ||||
| 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 | ||||
| from com.models import News, NewsDate | ||||
| from core.markdown import markdown | ||||
| from core.models import User | ||||
|  | ||||
|  | ||||
| @@ -184,3 +188,63 @@ class TestDeleteNews: | ||||
|         ) | ||||
|         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_moderated=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_moderated=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]] | ||||
|   | ||||
							
								
								
									
										30
									
								
								com/views.py
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								com/views.py
									
									
									
									
									
								
							| @@ -22,7 +22,7 @@ | ||||
| # | ||||
| # | ||||
| import itertools | ||||
| from datetime import timedelta | ||||
| from datetime import date, timedelta | ||||
| from smtplib import SMTPRecipientsRefused | ||||
| from typing import Any | ||||
|  | ||||
| @@ -253,19 +253,41 @@ class NewsListView(TemplateView): | ||||
|             key=lambda u: u.date_of_birth.year, | ||||
|         ) | ||||
|  | ||||
|     def get_news_dates(self): | ||||
|     def get_last_day(self) -> date: | ||||
|         """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. | ||||
|         """ | ||||
|         return list( | ||||
|             NewsDate.objects.filter(end_date__gt=now()) | ||||
|             .order_by("start_date") | ||||
|             .values_list("start_date__date", flat=True) | ||||
|             .distinct()[:4] | ||||
|         )[-1] | ||||
|  | ||||
|     def get_news_dates(self, until: date): | ||||
|         """Return the event dates to display. | ||||
|  | ||||
|         The selected events are the ones that happens between | ||||
|         right now and the given day (included). | ||||
|         """ | ||||
|         return itertools.groupby( | ||||
|             NewsDate.objects.viewable_by(self.request.user) | ||||
|             .filter(end_date__gt=now(), start_date__lt=now() + timedelta(days=6)) | ||||
|             .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(), | ||||
|             "news_dates": self.get_news_dates(until=last_day), | ||||
|             "birthdays": self.get_birthdays(), | ||||
|             "last_day": last_day, | ||||
|         } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
| msgid "" | ||||
| msgstr "" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-02-18 15:03+0100\n" | ||||
| "POT-Creation-Date: 2025-02-25 11:04+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" | ||||
| @@ -1426,17 +1426,6 @@ msgstr "" | ||||
| "Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas " | ||||
| "modérée." | ||||
|  | ||||
| #: com/templates/com/macros.jinja | ||||
| #, python-format | ||||
| msgid "" | ||||
| "This event will take place every week for %(nb)s weeks. If you moderate or " | ||||
| "delete this event, it will also be moderated (or deleted) for the following " | ||||
| "weeks." | ||||
| msgstr "" | ||||
| "Cet événement se déroulera chaque semaine pendant %(nb)s semaines. Si vous " | ||||
| "modérez ou supprimez cet événement, il sera également modéré (ou supprimé) " | ||||
| "pour les semaines suivantes." | ||||
|  | ||||
| #: com/templates/com/macros.jinja 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 | ||||
| @@ -1578,6 +1567,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" | ||||
| @@ -6031,3 +6031,13 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée." | ||||
| #, python-format | ||||
| msgid "Maximum characters: %(max_length)s" | ||||
| msgstr "Nombre de caractères max: %(max_length)s" | ||||
|  | ||||
| #, python-format | ||||
| #~ msgid "" | ||||
| #~ "This event will take place every week for %%s weeks. If you moderate or " | ||||
| #~ "delete this event, it will also be moderated (or deleted) for the " | ||||
| #~ "following weeks." | ||||
| #~ msgstr "" | ||||
| #~ "Cet événement se déroulera chaque semaine pendant %%s semaines. Si vous " | ||||
| #~ "modérez ou supprimez cet événement, il sera également modéré (ou " | ||||
| #~ "supprimé) pour les semaines suivantes." | ||||
|   | ||||
| @@ -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 11:05+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,17 @@ msgstr "" | ||||
| msgid "More info" | ||||
| msgstr "Plus d'informations" | ||||
|  | ||||
| #: com/static/bundled/com/components/moderation-alert-index.ts | ||||
| #, javascript-format | ||||
| msgid "" | ||||
| "This event will take place every week for %s weeks. If you moderate or " | ||||
| "delete this event, it will also be moderated (or deleted) for the following " | ||||
| "weeks." | ||||
| msgstr "" | ||||
| "Cet événement se déroulera chaque semaine pendant %s semaines. Si vous " | ||||
| "modérez ou supprimez cet événement, il sera également modéré (ou supprimé) " | ||||
| "pour les semaines suivantes." | ||||
|  | ||||
| #: core/static/bundled/core/components/ajax-select-base.ts | ||||
| msgid "Remove" | ||||
| msgstr "Retirer" | ||||
| @@ -125,10 +136,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 +194,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" | ||||
|   | ||||
| @@ -171,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", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user