From eb81c2438a090641cb1f9a202a87884c1a8b004b Mon Sep 17 00:00:00 2001 From: SebF Date: Sun, 27 Apr 2025 12:19:54 +0200 Subject: [PATCH 1/3] =?UTF-8?q?WIP=20S=C3=A9paration=20des=20models=20:=20?= =?UTF-8?q?probl=C3=A8me=20de=20r=C3=A9f=C3=A9rence=20circulaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agenda_culturel/models/__init__.py | 13 + src/agenda_culturel/models/category.py | 250 ++++ src/agenda_culturel/models/configuration.py | 89 ++ .../{models.py => models/event.py} | 1237 +---------------- src/agenda_culturel/models/import_batch.py | 69 + .../models/import_recurrent.py | 165 +++ src/agenda_culturel/models/message.py | 121 ++ src/agenda_culturel/models/organisation.py | 52 + src/agenda_culturel/models/place.py | 161 +++ src/agenda_culturel/models/special_period.py | 109 ++ src/agenda_culturel/models/static_content.py | 34 + src/agenda_culturel/models/tag.py | 158 +++ src/agenda_culturel/models/user.py | 36 + src/agenda_culturel/models/utils.py | 19 + 14 files changed, 1303 insertions(+), 1210 deletions(-) create mode 100644 src/agenda_culturel/models/__init__.py create mode 100644 src/agenda_culturel/models/category.py create mode 100644 src/agenda_culturel/models/configuration.py rename src/agenda_culturel/{models.py => models/event.py} (61%) create mode 100644 src/agenda_culturel/models/import_batch.py create mode 100644 src/agenda_culturel/models/import_recurrent.py create mode 100644 src/agenda_culturel/models/message.py create mode 100644 src/agenda_culturel/models/organisation.py create mode 100644 src/agenda_culturel/models/place.py create mode 100644 src/agenda_culturel/models/special_period.py create mode 100644 src/agenda_culturel/models/static_content.py create mode 100644 src/agenda_culturel/models/tag.py create mode 100644 src/agenda_culturel/models/user.py create mode 100644 src/agenda_culturel/models/utils.py diff --git a/src/agenda_culturel/models/__init__.py b/src/agenda_culturel/models/__init__.py new file mode 100644 index 0000000..ba6f09d --- /dev/null +++ b/src/agenda_culturel/models/__init__.py @@ -0,0 +1,13 @@ +from .utils import * +from .configuration import * +from .user import * +from .static_content import * +from .tag import * +from .place import * +from .organisation import * +from .event import * +from .message import * +from .import_recurrent import * +from .import_batch import * +from .category import * +from .special_period import * diff --git a/src/agenda_culturel/models/category.py b/src/agenda_culturel/models/category.py new file mode 100644 index 0000000..9fdd695 --- /dev/null +++ b/src/agenda_culturel/models/category.py @@ -0,0 +1,250 @@ +from collections import defaultdict + +from autoslug import AutoSlugField +from colorfield.fields import ColorField +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from ..models import Place, remove_accents + + +class Category(models.Model): + default_name = "Sans catégorie" + default_css_class = "cat-nocat" + default_color = "#aaaaaa" + + COLOR_PALETTE = [ + ("#ea5545", "color 1"), + ("#f46a9b", "color 2"), + ("#ef9b20", "color 3"), + ("#edbf33", "color 4"), + ("#ede15b", "color 5"), + ("#bdcf32", "color 6"), + ("#87bc45", "color 7"), + ("#27aeef", "color 8"), + ("#b33dc6", "color 9"), + ] + + name = models.CharField( + verbose_name=_("Name"), help_text=_("Category name"), max_length=512 + ) + + slug = AutoSlugField(null=True, default=None, unique=True, populate_from="name") + + color = ColorField( + verbose_name=_("Color"), + help_text=_("Color used as background for the category"), + blank=True, + null=True, + ) + + pictogram = models.FileField( + verbose_name=_("Pictogram"), + help_text=_("Pictogram of the category (svg format)"), + max_length=1024, + blank=True, + null=True, + ) + + position = models.IntegerField( + verbose_name=_("Position for ordering categories"), default=0 + ) + + def save(self, *args, **kwargs): + if self.color is None: + existing_colors = [c.color for c in Category.objects.all()] + if len(existing_colors) > len(Category.COLOR_PALETTE): + self.color = "#CCCCCC" + else: + for c, n in Category.COLOR_PALETTE: + if c not in existing_colors: + self.color = c + break + if self.color is None: + self.color = "#CCCCCC" + + super(Category, self).save(*args, **kwargs) + + @classmethod + def get_default_category(cls): + try: + # try to get an existing category + default = Category.objects.get(name=Category.default_name) + + return default + except Exception: + # if it does not exist, return it + default, created = Category.objects.get_or_create( + name=Category.default_name, + color=Category.default_color, + ) + return default + + @classmethod + def get_default_category_id(cls): + cat = Category.get_default_category() + if cat: + return cat.id + else: + return None + + def css_class(self): + return "cat-" + str(self.id) + + def get_absolute_url(self): + return reverse("home_category", kwargs={"cat": self.slug}) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("Category") + verbose_name_plural = _("Categories") + indexes = [ + models.Index(fields=["name"]), + ] + + +class CategorisationRule(models.Model): + weight = models.IntegerField( + verbose_name=_("Weight"), + help_text=_("The lower is the weight, the earlier the filter is applied"), + default=1, + ) + + category = models.ForeignKey( + Category, + verbose_name=_("Category"), + help_text=_("Category applied to the event"), + on_delete=models.CASCADE, + ) + + title_contains = models.CharField( + verbose_name=_("Contained in the title"), + help_text=_("Text contained in the event title"), + max_length=512, + blank=True, + null=True, + ) + title_exact = models.BooleanField( + verbose_name=_("Exact title extract"), + help_text=_( + "If checked, the extract will be searched for in the title using the exact form (capitals, accents)." + ), + default=False, + ) + + description_contains = models.CharField( + verbose_name=_("Contained in the description"), + help_text=_("Text contained in the description"), + max_length=512, + blank=True, + null=True, + ) + desc_exact = models.BooleanField( + verbose_name=_("Exact description extract"), + help_text=_( + "If checked, the extract will be searched for in the description using the exact form (capitals, accents)." + ), + default=False, + ) + + location_contains = models.CharField( + verbose_name=_("Contained in the location"), + help_text=_("Text contained in the event location"), + max_length=512, + blank=True, + null=True, + ) + loc_exact = models.BooleanField( + verbose_name=_("Exact location extract"), + help_text=_( + "If checked, the extract will be searched for in the location using the exact form (capitals, accents)." + ), + default=False, + ) + + place = models.ForeignKey( + Place, + verbose_name=_("Place"), + help_text=_("Location from place"), + null=True, + on_delete=models.SET_NULL, + blank=True, + ) + + rules = None + + class Meta: + verbose_name = _("Categorisation rule") + verbose_name_plural = _("Categorisation rules") + permissions = [("apply_categorisationrules", "Apply a categorisation rule")] + + # all rules are applied, starting from the first to the last + def apply_rules(event): + c = CategorisationRule.get_category_from_rules(event) + + if c is None: + return 0 + else: + event.category = c + return 1 + + def get_category_from_rules(event): + cats = defaultdict(lambda: 0) + if CategorisationRule.rules is None: + CategorisationRule.rules = ( + CategorisationRule.objects.all() + .select_related("category") + .select_related("place") + ) + + for rule in CategorisationRule.rules: + if rule.match(event): + cats[rule.category] += rule.weight + + if len(cats) == 0: + return None + else: + return max(cats, key=cats.get) + + def match(self, event): + if self.description_contains and self.description_contains != "": + if self.desc_exact: + result = self.description_contains in event.description + else: + result = event.description and ( + remove_accents(self.description_contains).lower() + in remove_accents(event.description).lower() + ) + if not result: + return False + + if self.title_contains and self.title_contains != "": + if self.title_exact: + result = self.title_contains in event.title + else: + result = ( + remove_accents(self.title_contains).lower() + in remove_accents(event.title).lower() + ) + if not result: + return False + + if self.location_contains and self.location_contains != "": + if self.loc_exact: + result = self.location_contains in event.location + else: + result = ( + remove_accents(self.location_contains).lower() + in remove_accents(event.location).lower() + ) + if not result: + return False + + if self.place: + if not event.exact_location == self.place: + return False + + return True diff --git a/src/agenda_culturel/models/configuration.py b/src/agenda_culturel/models/configuration.py new file mode 100644 index 0000000..4db8763 --- /dev/null +++ b/src/agenda_culturel/models/configuration.py @@ -0,0 +1,89 @@ +from django.conf import settings +from django.db import models +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ +from solo.models import SingletonModel + + +class SiteConfiguration(SingletonModel): + site_name = models.CharField( + verbose_name=_("Site name"), max_length=255, default="Pommes de lune" + ) + site_url = models.CharField( + verbose_name=_("Site url"), max_length=255, default="https://pommesdelune.fr" + ) + site_description = models.CharField( + verbose_name=_("Site description"), + max_length=255, + default="Agenda participatif des sorties culturelles à Clermont-Ferrand et aux environs", + ) + + html_keywords = models.CharField( + verbose_name=_("Keywords in html header"), + max_length=1024, + default="Clermont-Ferrand, Puy-de-Dôme, agenda culturel, agenda participatif, sortir à clermont, sorties, concerts, théâtre, danse, animations, ateliers, lectures", + ) + html_description = models.CharField( + verbose_name=_("Description in html header"), + max_length=1024, + default="Où sortir à Clermont-Ferrand? Retrouve tous les bons plans sur l'agenda participatif des événements culturels à Clermont-Ferrand et dans le Puy-de-Dôme", + ) + google_site_verification = models.CharField( + verbose_name=_("Google site verification value"), + max_length=255, + default=None, + blank=True, + null=True, + ) + ms_site_verification = models.CharField( + verbose_name=_("Microsoft (bing) site verification value"), + max_length=255, + default=None, + blank=True, + null=True, + ) + + favicon = models.ImageField( + verbose_name=_("Illustration"), + max_length=1024, + blank=True, + null=True, + ) + favicon_dev = models.ImageField( + verbose_name=_("Illustration (development version)"), + max_length=1024, + blank=True, + null=True, + ) + + def guess_mimetype(url): + if url.endswith("png"): + return "image/png" + if url.endswith("jpg"): + return "image/jpg" + if url.endswith("svg"): + return "image/svg+xml" + if url.endswith("ico"): + return "image/x-icon" + return "image/jpg" + + def get_favicon_url(self): + if settings.DEBUG: + if self.favicon_dev: + return self.favicon_dev.url + else: + return static("images/pdl-64-dev.png") + else: + if self.favicon: + return self.favicon.url + else: + return static("images/pdl-64.png") + + def get_favicon_mimetype(self): + return SiteConfiguration.guess_mimetype(self.get_favicon_url()) + + def __str__(self): + return str(_("Site Configuration")) + + class Meta: + verbose_name = _("Site Configuration") diff --git a/src/agenda_culturel/models.py b/src/agenda_culturel/models/event.py similarity index 61% rename from src/agenda_culturel/models.py rename to src/agenda_culturel/models/event.py index fee31d1..0898256 100644 --- a/src/agenda_culturel/models.py +++ b/src/agenda_culturel/models/event.py @@ -1,22 +1,16 @@ import copy -import hashlib import logging import os -import unicodedata +import urllib import urllib.request import uuid -from collections import defaultdict -from datetime import date, time, timedelta +from datetime import date, time, timedelta, datetime from urllib.parse import urlparse -import icalendar import emoji -import recurrence import recurrence.fields -from autoslug import AutoSlugField -from colorfield.fields import ColorField from django.contrib.auth.models import User -from django.contrib.gis.geos import Point +from django.contrib.gis.db.models.functions import Distance from django.contrib.postgres.search import TrigramSimilarity from django.contrib.sites.models import Site from django.core.cache import cache @@ -24,443 +18,37 @@ from django.core.cache.utils import make_template_fragment_key from django.core.files import File from django.core.files.storage import default_storage from django.core.mail import send_mail -from django.db import connection, models -from django.db.models import Count, F, Func, OuterRef, Q, Subquery, Value -from django.db.models.functions import Lower -from django.template.defaultfilters import date as _date -from django.template.defaultfilters import slugify +from django.db import models, connection +from django.db.models import Func, F, Q, Count +from django.db.models.expressions import RawSQL, Value, OuterRef, Subquery +from django.db.models.functions import Lower, ExtractDay, Now +from django.template.defaultfilters import date as _date, slugify from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone from django.utils.dateparse import parse_date -from django.utils.timezone import datetime from django.utils.translation import gettext_lazy as _ from django_better_admin_arrayfield.models.fields import ArrayField -from django_ckeditor_5.fields import CKEditor5Field from django_resized import ResizedImageField from icalendar import Calendar as icalCal from icalendar import Event as icalEvent -from location_field.models.spatial import LocationField -from django.contrib.gis.db.models.functions import Distance -from django.dispatch import receiver -from django.db.models.signals import post_save -from django.db.models.functions import Now -from django.db.models.functions import ExtractDay -from django.db.models.expressions import RawSQL -from django.templatetags.static import static -from django.conf import settings -from solo.models import SingletonModel -from django.core.exceptions import ValidationError -from .calendar import CalendarDay -from .import_tasks.extractor import Extractor +from ..models import ( + Category, + Place, + Organisation, + Message, + RecurrentImport, + remove_accents, + CategorisationRule, + SiteConfiguration, + Tag, +) +from ..calendar import CalendarDay +from ..import_tasks.extractor import Extractor logger = logging.getLogger(__name__) -# -# -# Useful for translation -to_be_translated = [_("mean"), _("median"), _("maximum"), _("minimum"), _("stdev")] - - -def no_slash_validator(value): - if "/" in value: - raise ValidationError(_("The '/' character is not allowed.")) - - -class SiteConfiguration(SingletonModel): - site_name = models.CharField( - verbose_name=_("Site name"), max_length=255, default="Pommes de lune" - ) - site_url = models.CharField( - verbose_name=_("Site url"), max_length=255, default="https://pommesdelune.fr" - ) - site_description = models.CharField( - verbose_name=_("Site description"), - max_length=255, - default="Agenda participatif des sorties culturelles à Clermont-Ferrand et aux environs", - ) - - html_keywords = models.CharField( - verbose_name=_("Keywords in html header"), - max_length=1024, - default="Clermont-Ferrand, Puy-de-Dôme, agenda culturel, agenda participatif, sortir à clermont, sorties, concerts, théâtre, danse, animations, ateliers, lectures", - ) - html_description = models.CharField( - verbose_name=_("Description in html header"), - max_length=1024, - default="Où sortir à Clermont-Ferrand? Retrouve tous les bons plans sur l'agenda participatif des événements culturels à Clermont-Ferrand et dans le Puy-de-Dôme", - ) - google_site_verification = models.CharField( - verbose_name=_("Google site verification value"), - max_length=255, - default=None, - blank=True, - null=True, - ) - ms_site_verification = models.CharField( - verbose_name=_("Microsoft (bing) site verification value"), - max_length=255, - default=None, - blank=True, - null=True, - ) - - favicon = models.ImageField( - verbose_name=_("Illustration"), - max_length=1024, - blank=True, - null=True, - ) - favicon_dev = models.ImageField( - verbose_name=_("Illustration (development version)"), - max_length=1024, - blank=True, - null=True, - ) - - def guess_mimetype(url): - if url.endswith("png"): - return "image/png" - if url.endswith("jpg"): - return "image/jpg" - if url.endswith("svg"): - return "image/svg+xml" - if url.endswith("ico"): - return "image/x-icon" - return "image/jpg" - - def get_favicon_url(self): - if settings.DEBUG: - if self.favicon_dev: - return self.favicon_dev.url - else: - return static("images/pdl-64-dev.png") - else: - if self.favicon: - return self.favicon.url - else: - return static("images/pdl-64.png") - - def get_favicon_mimetype(self): - return SiteConfiguration.guess_mimetype(self.get_favicon_url()) - - def __str__(self): - return str(_("Site Configuration")) - - class Meta: - verbose_name = _("Site Configuration") - - -class UserProfile(models.Model): - user = models.OneToOneField( - User, - on_delete=models.CASCADE, - primary_key=True, - ) - - is_moderation_expert = models.BooleanField( - verbose_name=_("Expert moderation user"), - help_text=_( - "This user is an expert in moderation, and the interface features additional functionalities." - ), - default=False, - ) - - class Meta: - verbose_name = _("User profile") - verbose_name_plural = _("User profiles") - - def __str__(self): - return _("User profile") + " (" + self.user.username + ")" - - -@receiver(post_save, sender=User) -def update_profile_signal(sender, instance, created, **kwargs): - if not hasattr(instance, "userprofile"): - UserProfile.objects.create(user=instance) - - instance.userprofile.save() - - -def remove_accents(input_str): - if input_str is None: - return None - nfkd_form = unicodedata.normalize("NFKD", input_str) - return "".join([c for c in nfkd_form if not unicodedata.combining(c)]) - - -class StaticContent(models.Model): - name = models.CharField( - verbose_name=_("Name"), - help_text=_("Category name"), - max_length=512, - unique=True, - ) - text = CKEditor5Field( - verbose_name=_("Content"), - help_text=_("Text as shown to the visitors"), - blank=True, - ) - url_path = models.CharField( - verbose_name=_("URL path"), - help_text=_("URL path where the content is included."), - ) - - class Meta: - verbose_name = _("Static content") - verbose_name_plural = _("Static contents") - indexes = [ - models.Index(fields=["name"]), - ] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return self.url_path - - -class Category(models.Model): - default_name = "Sans catégorie" - default_css_class = "cat-nocat" - default_color = "#aaaaaa" - - COLOR_PALETTE = [ - ("#ea5545", "color 1"), - ("#f46a9b", "color 2"), - ("#ef9b20", "color 3"), - ("#edbf33", "color 4"), - ("#ede15b", "color 5"), - ("#bdcf32", "color 6"), - ("#87bc45", "color 7"), - ("#27aeef", "color 8"), - ("#b33dc6", "color 9"), - ] - - name = models.CharField( - verbose_name=_("Name"), help_text=_("Category name"), max_length=512 - ) - - slug = AutoSlugField(null=True, default=None, unique=True, populate_from="name") - - color = ColorField( - verbose_name=_("Color"), - help_text=_("Color used as background for the category"), - blank=True, - null=True, - ) - - pictogram = models.FileField( - verbose_name=_("Pictogram"), - help_text=_("Pictogram of the category (svg format)"), - max_length=1024, - blank=True, - null=True, - ) - - position = models.IntegerField( - verbose_name=_("Position for ordering categories"), default=0 - ) - - def save(self, *args, **kwargs): - if self.color is None: - existing_colors = [c.color for c in Category.objects.all()] - if len(existing_colors) > len(Category.COLOR_PALETTE): - self.color = "#CCCCCC" - else: - for c, n in Category.COLOR_PALETTE: - if c not in existing_colors: - self.color = c - break - if self.color is None: - self.color = "#CCCCCC" - - super(Category, self).save(*args, **kwargs) - - def get_default_category(): - try: - # try to get an existing category - default = Category.objects.get(name=Category.default_name) - - return default - except Exception: - # if it does not exist, return it - default, created = Category.objects.get_or_create( - name=Category.default_name, - color=Category.default_color, - ) - return default - - def get_default_category_id(): - cat = Category.get_default_category() - if cat: - return cat.id - else: - return None - - def css_class(self): - return "cat-" + str(self.id) - - def get_absolute_url(self): - return reverse("home_category", kwargs={"cat": self.slug}) - - def __str__(self): - return self.name - - class Meta: - verbose_name = _("Category") - verbose_name_plural = _("Categories") - indexes = [ - models.Index(fields=["name"]), - ] - - -class Tag(models.Model): - name = models.CharField( - verbose_name=_("Name"), - help_text=_("Tag name"), - max_length=512, - unique=True, - validators=[no_slash_validator], - ) - - description = CKEditor5Field( - verbose_name=_("Description"), - help_text=_("Description of the tag"), - blank=True, - null=True, - ) - - message = CKEditor5Field( - verbose_name=_("Message"), - help_text=_( - "Message displayed to the user on each event associated with this tag." - ), - blank=True, - null=True, - ) - - principal = models.BooleanField( - verbose_name=_("Principal"), - help_text=_( - "This tag is highlighted as a main tag for visitors, particularly in the filter." - ), - default=False, - ) - - in_excluded_suggestions = models.BooleanField( - verbose_name=_("In excluded suggestions"), - help_text=_("This tag will be part of the excluded suggestions."), - default=False, - ) - - in_included_suggestions = models.BooleanField( - verbose_name=_("In included suggestions"), - help_text=_("This tag will be part of the included suggestions."), - default=False, - ) - - class Meta: - verbose_name = _("Tag") - verbose_name_plural = _("Tags") - indexes = [ - models.Index(fields=["name"]), - ] - - def get_absolute_url(self): - return reverse("view_tag", kwargs={"t": self.name}) - - def clear_cache(): - for exclude in [False, True]: - for include in [False, True]: - for nb_suggestions in [10]: - id_cache = ( - "all_tags " - + str(exclude) - + " " - + str(include) - + " " - + str(nb_suggestions) - ) - id_cache = hashlib.md5(id_cache.encode("utf8")).hexdigest() - cache.delete(id_cache) - - def get_tag_groups(nb_suggestions=10, exclude=False, include=False, all=False): - id_cache = ( - "all_tags " + str(exclude) + " " + str(include) + " " + str(nb_suggestions) - ) - id_cache = hashlib.md5(id_cache.encode("utf8")).hexdigest() - result = cache.get(id_cache) - - if not result: - free_tags = Event.get_all_tags(False) - f_tags = [t["tag"] for t in free_tags] - - obj_tags = Tag.objects - - if all: - obj_tags = obj_tags.filter( - Q(in_excluded_suggestions=True) - | Q(in_included_suggestions=True) - | Q(principal=True) - ) - else: - if exclude: - obj_tags = obj_tags.filter(Q(in_excluded_suggestions=True)) - if include: - obj_tags = obj_tags.filter( - Q(in_included_suggestions=True) | Q(principal=True) - ) - - if not exclude and not include: - obj_tags = obj_tags.filter(principal=True) - - obj_tags = obj_tags.values_list("name", flat=True) - - if len(obj_tags) > nb_suggestions: - nb_suggestions = len(obj_tags) - - tags = [ - { - "tag": t["tag"], - "count": 1000000 if t["tag"] in obj_tags else t["count"], - } - for t in free_tags - ] - tags += [ - {"tag": o, "count": 0} - for o in Tag.objects.filter(~Q(name__in=f_tags)).values_list( - "name", flat=True - ) - ] - - tags.sort(key=lambda x: -x["count"]) - - tags1 = tags[0:nb_suggestions] - tags1.sort( - key=lambda x: emoji.demojize( - remove_accents(x["tag"]).lower(), delimiters=("000", "") - ) - ) - tags2 = tags[nb_suggestions:] - tags2.sort( - key=lambda x: emoji.demojize( - remove_accents(x["tag"]).lower(), delimiters=("000", "") - ) - ) - - result = ( - (_("Suggestions"), [(t["tag"], t["tag"]) for t in tags1]), - (_("Others"), [(t["tag"], t["tag"]) for t in tags2]), - ) - - cache.set(id_cache, result, 3000) # 50mn - return result - - def __str__(self): - return self.name - class DuplicatedEvents(models.Model): representative = models.ForeignKey( @@ -566,7 +154,8 @@ class DuplicatedEvents(models.Model): def get_items_comparison(self): return Event.get_comparison(self.get_duplicated()) - def remove_singletons(): + @classmethod + def remove_singletons(cls): singletons = DuplicatedEvents.objects.annotate(nb_events=Count("event")).filter( nb_events__lte=1 ) @@ -611,199 +200,6 @@ class DuplicatedEvents(models.Model): return -1 -class ReferenceLocation(models.Model): - name = models.CharField( - verbose_name=_("Name"), - help_text=_("Name of the location"), - unique=True, - null=False, - ) - location = LocationField( - based_fields=["name"], - zoom=12, - default=Point(3.08333, 45.783329), - srid=4326, - ) - main = models.IntegerField( - verbose_name=_("Main"), - help_text=_( - "This location is one of the main locations (shown first higher values)." - ), - default=0, - ) - suggested_distance = models.IntegerField( - verbose_name=_("Suggested distance (km)"), - help_text=_( - "If this distance is given, this location is part of the suggested filters." - ), - null=True, - default=None, - ) - - slug = AutoSlugField(null=True, default=None, unique=True, populate_from="name") - - class Meta: - verbose_name = _("Reference location") - verbose_name_plural = _("Reference locations") - indexes = [ - models.Index(fields=["name"]), - ] - - def __str__(self): - return self.name - - -class Place(models.Model): - name = models.CharField(verbose_name=_("Name"), help_text=_("Name of the place")) - address = models.CharField( - verbose_name=_("Address"), - help_text=_("Address of this place (without city name)"), - blank=True, - null=True, - ) - postcode = models.CharField( - verbose_name=_("Postcode"), - help_text=_( - "The post code is not displayed, but makes it easier to find an address when you enter it." - ), - blank=True, - null=True, - ) - city = models.CharField(verbose_name=_("City"), help_text=_("City name")) - location = LocationField( - based_fields=["name", "address", "postcode", "city"], - zoom=12, - default=Point(3.08333, 45.783329), - ) - - description = CKEditor5Field( - verbose_name=_("Description"), - help_text=_("Description of the place, including accessibility."), - blank=True, - null=True, - ) - - aliases = ArrayField( - models.CharField(max_length=512), - verbose_name=_("Alternative names"), - help_text=_( - "Alternative names or addresses used to match a place with the free-form location of an event." - ), - blank=True, - null=True, - ) - - class Meta: - verbose_name = _("Place") - verbose_name_plural = _("Places") - ordering = ["name"] - indexes = [ - models.Index(fields=["name"]), - models.Index(fields=["city"]), - models.Index(fields=["location"]), - ] - - def __str__(self): - if self.address: - return self.name + ", " + self.address + ", " + self.city - else: - return self.name + ", " + self.city - - def get_absolute_url(self): - return reverse( - "view_place_fullname", - kwargs={"pk": self.pk, "extra": slugify(self.name)}, - ) - - def nb_events(self): - return Event.objects.filter(exact_location=self).count() - - def nb_events_future(self): - return ( - Event.objects.filter(start_day__gte=datetime.now()) - .filter(exact_location=self) - .count() - ) - - def match(self, event): - if self.aliases and event.location: - return event.location.strip() in self.aliases - else: - return False - - def associate_matching_events(self): - u_events = Event.objects.filter(exact_location__isnull=True) - - to_be_updated = [] - # try to find matches - for ue in u_events: - if self.match(ue): - ue.exact_location = self - to_be_updated.append(ue) - continue - # update events with a location - Event.objects.bulk_update(to_be_updated, fields=["exact_location"]) - return len(to_be_updated) - - def get_all_cities(): - try: - tags = list( - [ - p["city"] - for p in Place.objects.values("city").distinct().order_by("city") - ] - ) - except Exception: - tags = [] - return tags - - -class Organisation(models.Model): - name = models.CharField( - verbose_name=_("Name"), - help_text=_("Organisation name"), - max_length=512, - null=False, - unique=True, - ) - - website = models.URLField( - verbose_name=_("Website"), - help_text=_("Website of the organisation"), - max_length=1024, - blank=True, - null=True, - ) - - description = CKEditor5Field( - verbose_name=_("Description"), - help_text=_("Description of the organisation."), - blank=True, - null=True, - ) - - principal_place = models.ForeignKey( - Place, - verbose_name=_("Principal place"), - help_text=_( - "Place mainly associated with this organizer. Mainly used if there is a similarity in the name, to avoid redundant displays." - ), - null=True, - on_delete=models.SET_NULL, - blank=True, - ) - - class Meta: - verbose_name = _("Organisation") - verbose_name_plural = _("Organisations") - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("view_organisation", kwargs={"pk": self.pk, "extra": self.name}) - - class Event(models.Model): class STATUS(models.TextChoices): PUBLISHED = "published", _("Published") @@ -1511,10 +907,12 @@ class Event(models.Model): else: return False - def nb_draft_events(): + @classmethod + def nb_draft_events(cls): return Event.objects.filter(status=Event.STATUS.DRAFT).count() - def get_qs_events_with_unkwnon_place(): + @classmethod + def get_qs_events_with_unkwnon_place(cls): return ( Event.objects.filter(exact_location__isnull=True) .filter(~Q(status=Event.STATUS.TRASH)) @@ -2371,584 +1769,3 @@ class Event(models.Model): def get_count_modifications(when_list): return [Event.get_count_modification(w) for w in when_list] - - -class Message(models.Model): - class TYPE(models.TextChoices): - FROM_CONTRIBUTOR = "from_contributor", _("From contributor") - IMPORT_PROCESS = "import_process", _("Import process") - UPDATE_PROCESS = "update_process", _("Update process") - CONTACT_FORM = "contact_form", _("Contact form") - EVENT_REPORT = "event_report", _("Event report") - FROM_CONTRIBUTOR_NO_MSG = ( - "from_contrib_no_msg", - _("From contributor (without message)"), - ) - WARNING = "warning", _("Warning") - - class Meta: - verbose_name = _("Message") - verbose_name_plural = _("Messages") - indexes = [ - models.Index(fields=["related_event"]), - models.Index(fields=["user"]), - models.Index(fields=["date"]), - models.Index(fields=["spam", "closed"]), - ] - - subject = models.CharField( - verbose_name=_("Subject"), - help_text=_("The subject of your message"), - max_length=512, - ) - - related_event = models.ForeignKey( - Event, - verbose_name=_("Related event"), - help_text=_("The message is associated with this event."), - null=True, - default=None, - on_delete=models.SET_DEFAULT, - ) - - user = models.ForeignKey( - User, - verbose_name=_("Author of the message"), - null=True, - default=None, - on_delete=models.SET_DEFAULT, - ) - - name = models.CharField( - verbose_name=_("Name"), - help_text=_("Your name"), - max_length=512, - blank=True, - null=True, - ) - email = models.EmailField( - verbose_name=_("Email address"), - help_text=_("Your email address"), - max_length=254, - blank=True, - null=True, - ) - message = CKEditor5Field( - verbose_name=_("Message"), help_text=_("Your message"), blank=True - ) - - date = models.DateTimeField(auto_now_add=True) - - spam = models.BooleanField( - verbose_name=_("Spam"), - help_text=_("This message is a spam."), - default=False, - ) - - closed = models.BooleanField( - verbose_name=_("Closed"), - help_text=_( - "this message has been processed and no longer needs to be handled" - ), - default=False, - ) - comments = CKEditor5Field( - verbose_name=_("Comments"), - help_text=_("Comments on the message from the moderation team"), - default="", - blank=True, - null=True, - ) - - message_type = models.CharField( - verbose_name=_("Type"), - max_length=20, - choices=TYPE.choices, - default=None, - null=True, - ) - - def nb_open_messages(): - return Message.objects.filter( - Q(closed=False) - & Q(spam=False) - & Q( - message_type__in=[ - Message.TYPE.CONTACT_FORM, - Message.TYPE.EVENT_REPORT, - Message.TYPE.FROM_CONTRIBUTOR, - ] - ) - ).count() - - def get_absolute_url(self): - return reverse("message", kwargs={"pk": self.pk}) - - -class RecurrentImport(models.Model): - class Meta: - verbose_name = _("Recurrent import") - verbose_name_plural = _("Recurrent imports") - permissions = [("run_recurrentimport", "Can run a recurrent import")] - - class PROCESSOR(models.TextChoices): - ICAL = "ical", _("ical") - ICALNOBUSY = "icalnobusy", _("ical no busy") - ICALNOVC = "icalnovc", _("ical no VC") - ICALNAIVETZ = "ical naive tz", _("ical naive timezone") - LACOOPE = "lacoope", _("lacoope.org") - LACOMEDIE = "lacomedie", _("la comédie") - LEFOTOMAT = "lefotomat", _("le fotomat") - LAPUCEALOREILLE = "lapucealoreille", _("la puce à l'oreille") - MECWORDPRESS = "Plugin wordpress MEC", _("Plugin wordpress MEC") - FBEVENTS = "Facebook events", _("Événements d'une page FB") - BILLETTERIECF = "Billetterie CF", _("Billetterie Clermont-Ferrand") - ARACHNEE = "arachnee", _("Arachnée concert") - LERIO = "rio", _("Le Rio") - LARAYMONDE = "raymonde", _("La Raymonde") - APIDAE = "apidae", _("Agenda apidae tourisme") - IGUANA = "iguana", _("Agenda iguana (médiathèques)") - MILLEFORMES = "Mille formes", _("Mille formes") - AMISCERISES = "Amis cerises", _("Les Amis du Temps des Cerises") - MOBILIZON = "Mobilizon", _("Mobilizon") - LECAMELEON = "Le Caméléon", _("Le caméléon") - ECHOSCIENCES = "Echosciences", _("Echosciences") - HELLOASSO = "HelloAsso", _("Hello Asso") - - class DOWNLOADER(models.TextChoices): - SIMPLE = "simple", _("simple") - CHROMIUMHEADLESS = "chromium headless", _("Headless Chromium") - CHROMIUMHEADLESSPAUSE = ( - "chromium (pause)", - _("Headless Chromium (pause)"), - ) - - class RECURRENCE(models.TextChoices): - DAILY = ( - "daily", - _("daily"), - ) - WEEKLY = "weekly", _("weekly") - NEVER = "never", _("never") - - name = models.CharField( - verbose_name=_("Name"), - help_text=_( - "Recurrent import name. Be careful to choose a name that is easy to understand, as it will be public and displayed on the site" - "s About page." - ), - max_length=512, - default="", - ) - processor = models.CharField( - _("Processor"), - max_length=20, - choices=PROCESSOR.choices, - default=PROCESSOR.ICAL, - ) - downloader = models.CharField( - _("Downloader"), - max_length=20, - choices=DOWNLOADER.choices, - default=DOWNLOADER.SIMPLE, - ) - - recurrence = models.CharField( - _("Import recurrence"), - max_length=10, - choices=RECURRENCE.choices, - default=RECURRENCE.DAILY, - ) - - source = models.URLField( - verbose_name=_("Source"), - help_text=_("URL of the source document"), - unique=True, - max_length=1024, - ) - browsable_url = models.URLField( - verbose_name=_("Browsable url"), - help_text=_( - "URL of the corresponding document that will be shown to visitors." - ), - max_length=1024, - blank=True, - null=True, - ) - - defaultPublished = models.BooleanField( - verbose_name=_("Published"), - help_text=_("Status of each imported event (published or draft)"), - default=True, - ) - defaultLocation = models.CharField( - verbose_name=_("Location"), - help_text=_("Address for each imported event"), - max_length=512, - null=True, - blank=True, - ) - - forceLocation = models.BooleanField( - verbose_name=_("Force location"), - help_text=_("force location even if another is detected."), - default=False, - ) - - defaultOrganiser = models.ForeignKey( - Organisation, - verbose_name=_("Organiser"), - help_text=_("Organiser of each imported event"), - default=None, - null=True, - blank=True, - on_delete=models.SET_DEFAULT, - ) - - defaultCategory = models.ForeignKey( - Category, - verbose_name=_("Category"), - help_text=_("Category of each imported event"), - default=None, - null=True, - blank=True, - on_delete=models.SET_DEFAULT, - ) - defaultTags = ArrayField( - models.CharField(max_length=64), - verbose_name=_("Tags for each imported event"), - help_text=_("A list of tags that describe each imported event."), - blank=True, - null=True, - ) - - def __str__(self): - return self.name - - def nb_imports(self): - return BatchImportation.objects.filter(recurrentImport=self).count() - - def nb_events(self): - return Event.objects.filter(import_sources__contains=[self.source]).count() - - def get_absolute_url(self): - return reverse("view_rimport", kwargs={"pk": self.pk}) - - def last_import(self): - events = BatchImportation.objects.filter(recurrentImport=self).order_by( - "-created_date" - ) - if events and len(events) > 0: - return events[0] - else: - return None - - -class BatchImportation(models.Model): - class STATUS(models.TextChoices): - RUNNING = "running", _("Running") - CANCELED = "canceled", _("Canceled") - SUCCESS = "success", _("Success") - FAILED = "failed", _("Failed") - - class Meta: - verbose_name = _("Batch importation") - verbose_name_plural = _("Batch importations") - permissions = [("run_batchimportation", "Can run a batch importation")] - indexes = [ - models.Index(fields=["created_date"]), - models.Index(fields=["status"]), - models.Index(fields=["created_date", "recurrentImport"]), - ] - - created_date = models.DateTimeField(auto_now_add=True) - - recurrentImport = models.ForeignKey( - RecurrentImport, - verbose_name=_("Recurrent import"), - help_text=_("Reference to the recurrent import processing"), - blank=True, - null=True, - on_delete=models.SET_NULL, - editable=False, - ) - - url_source = models.URLField( - verbose_name=_("URL (if not recurrent import)"), - help_text=_("Source URL if no RecurrentImport is associated."), - max_length=1024, - blank=True, - null=True, - editable=False, - ) - - status = models.CharField( - _("Status"), - max_length=20, - choices=STATUS.choices, - default=STATUS.RUNNING, - ) - - error_message = models.CharField( - verbose_name=_("Error message"), max_length=512, blank=True, null=True - ) - - nb_initial = models.PositiveIntegerField( - verbose_name=_("Number of collected events"), default=0 - ) - nb_imported = models.PositiveIntegerField( - verbose_name=_("Number of imported events"), default=0 - ) - nb_updated = models.PositiveIntegerField( - verbose_name=_("Number of updated events"), default=0 - ) - nb_removed = models.PositiveIntegerField( - verbose_name=_("Number of removed events"), default=0 - ) - - celery_id = models.CharField(max_length=128, default="") - - -class CategorisationRule(models.Model): - weight = models.IntegerField( - verbose_name=_("Weight"), - help_text=_("The lower is the weight, the earlier the filter is applied"), - default=1, - ) - - category = models.ForeignKey( - Category, - verbose_name=_("Category"), - help_text=_("Category applied to the event"), - on_delete=models.CASCADE, - ) - - title_contains = models.CharField( - verbose_name=_("Contained in the title"), - help_text=_("Text contained in the event title"), - max_length=512, - blank=True, - null=True, - ) - title_exact = models.BooleanField( - verbose_name=_("Exact title extract"), - help_text=_( - "If checked, the extract will be searched for in the title using the exact form (capitals, accents)." - ), - default=False, - ) - - description_contains = models.CharField( - verbose_name=_("Contained in the description"), - help_text=_("Text contained in the description"), - max_length=512, - blank=True, - null=True, - ) - desc_exact = models.BooleanField( - verbose_name=_("Exact description extract"), - help_text=_( - "If checked, the extract will be searched for in the description using the exact form (capitals, accents)." - ), - default=False, - ) - - location_contains = models.CharField( - verbose_name=_("Contained in the location"), - help_text=_("Text contained in the event location"), - max_length=512, - blank=True, - null=True, - ) - loc_exact = models.BooleanField( - verbose_name=_("Exact location extract"), - help_text=_( - "If checked, the extract will be searched for in the location using the exact form (capitals, accents)." - ), - default=False, - ) - - place = models.ForeignKey( - Place, - verbose_name=_("Place"), - help_text=_("Location from place"), - null=True, - on_delete=models.SET_NULL, - blank=True, - ) - - rules = None - - class Meta: - verbose_name = _("Categorisation rule") - verbose_name_plural = _("Categorisation rules") - permissions = [("apply_categorisationrules", "Apply a categorisation rule")] - - # all rules are applied, starting from the first to the last - def apply_rules(event): - c = CategorisationRule.get_category_from_rules(event) - - if c is None: - return 0 - else: - event.category = c - return 1 - - def get_category_from_rules(event): - cats = defaultdict(lambda: 0) - if CategorisationRule.rules is None: - CategorisationRule.rules = ( - CategorisationRule.objects.all() - .select_related("category") - .select_related("place") - ) - - for rule in CategorisationRule.rules: - if rule.match(event): - cats[rule.category] += rule.weight - - if len(cats) == 0: - return None - else: - return max(cats, key=cats.get) - - def match(self, event): - if self.description_contains and self.description_contains != "": - if self.desc_exact: - result = self.description_contains in event.description - else: - result = event.description and ( - remove_accents(self.description_contains).lower() - in remove_accents(event.description).lower() - ) - if not result: - return False - - if self.title_contains and self.title_contains != "": - if self.title_exact: - result = self.title_contains in event.title - else: - result = ( - remove_accents(self.title_contains).lower() - in remove_accents(event.title).lower() - ) - if not result: - return False - - if self.location_contains and self.location_contains != "": - if self.loc_exact: - result = self.location_contains in event.location - else: - result = ( - remove_accents(self.location_contains).lower() - in remove_accents(event.location).lower() - ) - if not result: - return False - - if self.place: - if not event.exact_location == self.place: - return False - - return True - - -class SpecialPeriod(models.Model): - - name = models.CharField(verbose_name=_("Period name"), max_length=255) - start_date = models.DateField(verbose_name=_("Start day")) - end_date = models.DateField(verbose_name=_("End day")) - - class Meta: - verbose_name = _("Special period") - verbose_name_plural = _("Special periods") - indexes = [ - models.Index(fields=["start_date", "end_date"]), - ] - - class PERIODTYPE(models.TextChoices): - PUBLICHOLIDAYS = "public holidays", _("public holidays") - SCHOOLVACATIONS = "school vacations", _("school vacations") - - periodtype = models.CharField( - _("Period type"), - max_length=20, - choices=PERIODTYPE.choices, - default=PERIODTYPE.PUBLICHOLIDAYS, - ) - - def clean(self): - # Call the parent class's clean() method - super().clean() - - # Check that the end date is after or equal to the start date - if self.end_date < self.start_date: - raise ValidationError( - { - "end_date": _( - "The end date must be after or equal to the start date." - ) - } - ) - - def __str__(self): - n = self.periodtype + ' "' + self.name + '"' - if self.start_date == self.end_date: - return n + _(" on ") + str(self.start_date) - else: - return ( - n + _(" from ") + str(self.start_date) + _(" to ") + str(self.end_date) - ) - - def load_from_ical(uploaded_file, periodtype): - # load file - try: - file_content = uploaded_file.read() - calendar = icalendar.Calendar.from_ical(file_content) - - nb_error = 0 - nb_overlap = 0 - periods = [] - periods_to_create = [] - - # extract events - for event in calendar.walk("VEVENT"): - try: - name = event.decoded("SUMMARY").decode() - r = event.decoded("DTSTART") - if isinstance(r, datetime): - start_date = r.date() - elif isinstance(r, date): - start_date = r - r = event.decoded("DTEND") - if isinstance(r, datetime): - end_date = r.date() - elif isinstance(r, date): - end_date = r - periods.append( - SpecialPeriod( - name=name, - periodtype=periodtype, - start_date=start_date, - end_date=end_date, - ) - ) - except Exception: - nb_error += 1 - - for p in periods: - overlap_exists = SpecialPeriod.objects.filter( - Q(periodtype=p.periodtype) - & Q(start_date__lte=p.end_date) - & Q(end_date__gte=p.start_date) - ).exists() - - if overlap_exists: - nb_overlap += 1 - else: - periods_to_create.append(p) - - SpecialPeriod.objects.bulk_create(periods_to_create) - return len(periods_to_create), nb_overlap, nb_error, None - except Exception as e: - return 0, 0, 0, str(e) diff --git a/src/agenda_culturel/models/import_batch.py b/src/agenda_culturel/models/import_batch.py new file mode 100644 index 0000000..013afc1 --- /dev/null +++ b/src/agenda_culturel/models/import_batch.py @@ -0,0 +1,69 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ..models import RecurrentImport + + +class BatchImportation(models.Model): + class STATUS(models.TextChoices): + RUNNING = "running", _("Running") + CANCELED = "canceled", _("Canceled") + SUCCESS = "success", _("Success") + FAILED = "failed", _("Failed") + + class Meta: + verbose_name = _("Batch importation") + verbose_name_plural = _("Batch importations") + permissions = [("run_batchimportation", "Can run a batch importation")] + indexes = [ + models.Index(fields=["created_date"]), + models.Index(fields=["status"]), + models.Index(fields=["created_date", "recurrentImport"]), + ] + + created_date = models.DateTimeField(auto_now_add=True) + + recurrentImport = models.ForeignKey( + RecurrentImport, + verbose_name=_("Recurrent import"), + help_text=_("Reference to the recurrent import processing"), + blank=True, + null=True, + on_delete=models.SET_NULL, + editable=False, + ) + + url_source = models.URLField( + verbose_name=_("URL (if not recurrent import)"), + help_text=_("Source URL if no RecurrentImport is associated."), + max_length=1024, + blank=True, + null=True, + editable=False, + ) + + status = models.CharField( + _("Status"), + max_length=20, + choices=STATUS.choices, + default=STATUS.RUNNING, + ) + + error_message = models.CharField( + verbose_name=_("Error message"), max_length=512, blank=True, null=True + ) + + nb_initial = models.PositiveIntegerField( + verbose_name=_("Number of collected events"), default=0 + ) + nb_imported = models.PositiveIntegerField( + verbose_name=_("Number of imported events"), default=0 + ) + nb_updated = models.PositiveIntegerField( + verbose_name=_("Number of updated events"), default=0 + ) + nb_removed = models.PositiveIntegerField( + verbose_name=_("Number of removed events"), default=0 + ) + + celery_id = models.CharField(max_length=128, default="") diff --git a/src/agenda_culturel/models/import_recurrent.py b/src/agenda_culturel/models/import_recurrent.py new file mode 100644 index 0000000..b3d5311 --- /dev/null +++ b/src/agenda_culturel/models/import_recurrent.py @@ -0,0 +1,165 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django_better_admin_arrayfield.models.fields import ArrayField + +from ..models import Organisation, Category, BatchImportation, Event + + +class RecurrentImport(models.Model): + class Meta: + verbose_name = _("Recurrent import") + verbose_name_plural = _("Recurrent imports") + permissions = [("run_recurrentimport", "Can run a recurrent import")] + + class PROCESSOR(models.TextChoices): + ICAL = "ical", _("ical") + ICALNOBUSY = "icalnobusy", _("ical no busy") + ICALNOVC = "icalnovc", _("ical no VC") + ICALNAIVETZ = "ical naive tz", _("ical naive timezone") + LACOOPE = "lacoope", _("lacoope.org") + LACOMEDIE = "lacomedie", _("la comédie") + LEFOTOMAT = "lefotomat", _("le fotomat") + LAPUCEALOREILLE = "lapucealoreille", _("la puce à l'oreille") + MECWORDPRESS = "Plugin wordpress MEC", _("Plugin wordpress MEC") + FBEVENTS = "Facebook events", _("Événements d'une page FB") + BILLETTERIECF = "Billetterie CF", _("Billetterie Clermont-Ferrand") + ARACHNEE = "arachnee", _("Arachnée concert") + LERIO = "rio", _("Le Rio") + LARAYMONDE = "raymonde", _("La Raymonde") + APIDAE = "apidae", _("Agenda apidae tourisme") + IGUANA = "iguana", _("Agenda iguana (médiathèques)") + MILLEFORMES = "Mille formes", _("Mille formes") + AMISCERISES = "Amis cerises", _("Les Amis du Temps des Cerises") + MOBILIZON = "Mobilizon", _("Mobilizon") + LECAMELEON = "Le Caméléon", _("Le caméléon") + ECHOSCIENCES = "Echosciences", _("Echosciences") + HELLOASSO = "HelloAsso", _("Hello Asso") + + class DOWNLOADER(models.TextChoices): + SIMPLE = "simple", _("simple") + CHROMIUMHEADLESS = "chromium headless", _("Headless Chromium") + CHROMIUMHEADLESSPAUSE = ( + "chromium (pause)", + _("Headless Chromium (pause)"), + ) + + class RECURRENCE(models.TextChoices): + DAILY = ( + "daily", + _("daily"), + ) + WEEKLY = "weekly", _("weekly") + NEVER = "never", _("never") + + name = models.CharField( + verbose_name=_("Name"), + help_text=_( + "Recurrent import name. Be careful to choose a name that is easy to understand, as it will be public and displayed on the site" + "s About page." + ), + max_length=512, + default="", + ) + processor = models.CharField( + _("Processor"), + max_length=20, + choices=PROCESSOR.choices, + default=PROCESSOR.ICAL, + ) + downloader = models.CharField( + _("Downloader"), + max_length=20, + choices=DOWNLOADER.choices, + default=DOWNLOADER.SIMPLE, + ) + + recurrence = models.CharField( + _("Import recurrence"), + max_length=10, + choices=RECURRENCE.choices, + default=RECURRENCE.DAILY, + ) + + source = models.URLField( + verbose_name=_("Source"), + help_text=_("URL of the source document"), + unique=True, + max_length=1024, + ) + browsable_url = models.URLField( + verbose_name=_("Browsable url"), + help_text=_( + "URL of the corresponding document that will be shown to visitors." + ), + max_length=1024, + blank=True, + null=True, + ) + + defaultPublished = models.BooleanField( + verbose_name=_("Published"), + help_text=_("Status of each imported event (published or draft)"), + default=True, + ) + defaultLocation = models.CharField( + verbose_name=_("Location"), + help_text=_("Address for each imported event"), + max_length=512, + null=True, + blank=True, + ) + + forceLocation = models.BooleanField( + verbose_name=_("Force location"), + help_text=_("force location even if another is detected."), + default=False, + ) + + defaultOrganiser = models.ForeignKey( + Organisation, + verbose_name=_("Organiser"), + help_text=_("Organiser of each imported event"), + default=None, + null=True, + blank=True, + on_delete=models.SET_DEFAULT, + ) + + defaultCategory = models.ForeignKey( + Category, + verbose_name=_("Category"), + help_text=_("Category of each imported event"), + default=None, + null=True, + blank=True, + on_delete=models.SET_DEFAULT, + ) + defaultTags = ArrayField( + models.CharField(max_length=64), + verbose_name=_("Tags for each imported event"), + help_text=_("A list of tags that describe each imported event."), + blank=True, + null=True, + ) + + def __str__(self): + return self.name + + def nb_imports(self): + return BatchImportation.objects.filter(recurrentImport=self).count() + + def nb_events(self): + return Event.objects.filter(import_sources__contains=[self.source]).count() + + def get_absolute_url(self): + return reverse("view_rimport", kwargs={"pk": self.pk}) + + def last_import(self): + events = BatchImportation.objects.filter(recurrentImport=self).order_by( + "-created_date" + ) + if events and len(events) > 0: + return events[0] + else: + return None diff --git a/src/agenda_culturel/models/message.py b/src/agenda_culturel/models/message.py new file mode 100644 index 0000000..211e079 --- /dev/null +++ b/src/agenda_culturel/models/message.py @@ -0,0 +1,121 @@ +from django.contrib.auth.models import User +from django.db import models +from django.db.models import Q +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django_ckeditor_5.fields import CKEditor5Field + +from ..models import Event + + +class Message(models.Model): + class TYPE(models.TextChoices): + FROM_CONTRIBUTOR = "from_contributor", _("From contributor") + IMPORT_PROCESS = "import_process", _("Import process") + UPDATE_PROCESS = "update_process", _("Update process") + CONTACT_FORM = "contact_form", _("Contact form") + EVENT_REPORT = "event_report", _("Event report") + FROM_CONTRIBUTOR_NO_MSG = ( + "from_contrib_no_msg", + _("From contributor (without message)"), + ) + WARNING = "warning", _("Warning") + + class Meta: + verbose_name = _("Message") + verbose_name_plural = _("Messages") + indexes = [ + models.Index(fields=["related_event"]), + models.Index(fields=["user"]), + models.Index(fields=["date"]), + models.Index(fields=["spam", "closed"]), + ] + + subject = models.CharField( + verbose_name=_("Subject"), + help_text=_("The subject of your message"), + max_length=512, + ) + + related_event = models.ForeignKey( + Event, + verbose_name=_("Related event"), + help_text=_("The message is associated with this event."), + null=True, + default=None, + on_delete=models.SET_DEFAULT, + ) + + user = models.ForeignKey( + User, + verbose_name=_("Author of the message"), + null=True, + default=None, + on_delete=models.SET_DEFAULT, + ) + + name = models.CharField( + verbose_name=_("Name"), + help_text=_("Your name"), + max_length=512, + blank=True, + null=True, + ) + email = models.EmailField( + verbose_name=_("Email address"), + help_text=_("Your email address"), + max_length=254, + blank=True, + null=True, + ) + message = CKEditor5Field( + verbose_name=_("Message"), help_text=_("Your message"), blank=True + ) + + date = models.DateTimeField(auto_now_add=True) + + spam = models.BooleanField( + verbose_name=_("Spam"), + help_text=_("This message is a spam."), + default=False, + ) + + closed = models.BooleanField( + verbose_name=_("Closed"), + help_text=_( + "this message has been processed and no longer needs to be handled" + ), + default=False, + ) + comments = CKEditor5Field( + verbose_name=_("Comments"), + help_text=_("Comments on the message from the moderation team"), + default="", + blank=True, + null=True, + ) + + message_type = models.CharField( + verbose_name=_("Type"), + max_length=20, + choices=TYPE.choices, + default=None, + null=True, + ) + + @classmethod + def nb_open_messages(cls): + return Message.objects.filter( + Q(closed=False) + & Q(spam=False) + & Q( + message_type__in=[ + Message.TYPE.CONTACT_FORM, + Message.TYPE.EVENT_REPORT, + Message.TYPE.FROM_CONTRIBUTOR, + ] + ) + ).count() + + def get_absolute_url(self): + return reverse("message", kwargs={"pk": self.pk}) diff --git a/src/agenda_culturel/models/organisation.py b/src/agenda_culturel/models/organisation.py new file mode 100644 index 0000000..85d2ea4 --- /dev/null +++ b/src/agenda_culturel/models/organisation.py @@ -0,0 +1,52 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django_ckeditor_5.fields import CKEditor5Field + +from ..models import Place + + +class Organisation(models.Model): + name = models.CharField( + verbose_name=_("Name"), + help_text=_("Organisation name"), + max_length=512, + null=False, + unique=True, + ) + + website = models.URLField( + verbose_name=_("Website"), + help_text=_("Website of the organisation"), + max_length=1024, + blank=True, + null=True, + ) + + description = CKEditor5Field( + verbose_name=_("Description"), + help_text=_("Description of the organisation."), + blank=True, + null=True, + ) + + principal_place = models.ForeignKey( + Place, + verbose_name=_("Principal place"), + help_text=_( + "Place mainly associated with this organizer. Mainly used if there is a similarity in the name, to avoid redundant displays." + ), + null=True, + on_delete=models.SET_NULL, + blank=True, + ) + + class Meta: + verbose_name = _("Organisation") + verbose_name_plural = _("Organisations") + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("view_organisation", kwargs={"pk": self.pk, "extra": self.name}) diff --git a/src/agenda_culturel/models/place.py b/src/agenda_culturel/models/place.py new file mode 100644 index 0000000..8d84f63 --- /dev/null +++ b/src/agenda_culturel/models/place.py @@ -0,0 +1,161 @@ +from datetime import datetime + +from django.contrib.gis.geos import Point +from django.db import models +from django.template.defaultfilters import slugify +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django_better_admin_arrayfield.models.fields import ArrayField +from django_ckeditor_5.fields import CKEditor5Field +from django_extensions.db.fields import AutoSlugField +from location_field.models.spatial import LocationField + +from ..models import Event + + +class Place(models.Model): + name = models.CharField(verbose_name=_("Name"), help_text=_("Name of the place")) + address = models.CharField( + verbose_name=_("Address"), + help_text=_("Address of this place (without city name)"), + blank=True, + null=True, + ) + postcode = models.CharField( + verbose_name=_("Postcode"), + help_text=_( + "The post code is not displayed, but makes it easier to find an address when you enter it." + ), + blank=True, + null=True, + ) + city = models.CharField(verbose_name=_("City"), help_text=_("City name")) + location = LocationField( + based_fields=["name", "address", "postcode", "city"], + zoom=12, + default=Point(3.08333, 45.783329), + ) + + description = CKEditor5Field( + verbose_name=_("Description"), + help_text=_("Description of the place, including accessibility."), + blank=True, + null=True, + ) + + aliases = ArrayField( + models.CharField(max_length=512), + verbose_name=_("Alternative names"), + help_text=_( + "Alternative names or addresses used to match a place with the free-form location of an event." + ), + blank=True, + null=True, + ) + + class Meta: + verbose_name = _("Place") + verbose_name_plural = _("Places") + ordering = ["name"] + indexes = [ + models.Index(fields=["name"]), + models.Index(fields=["city"]), + models.Index(fields=["location"]), + ] + + def __str__(self): + if self.address: + return self.name + ", " + self.address + ", " + self.city + else: + return self.name + ", " + self.city + + def get_absolute_url(self): + return reverse( + "view_place_fullname", + kwargs={"pk": self.pk, "extra": slugify(self.name)}, + ) + + def nb_events(self): + return Event.objects.filter(exact_location=self).count() + + def nb_events_future(self): + return ( + Event.objects.filter(start_day__gte=datetime.now()) + .filter(exact_location=self) + .count() + ) + + def match(self, event): + if self.aliases and event.location: + return event.location.strip() in self.aliases + else: + return False + + def associate_matching_events(self): + u_events = Event.objects.filter(exact_location__isnull=True) + + to_be_updated = [] + # try to find matches + for ue in u_events: + if self.match(ue): + ue.exact_location = self + to_be_updated.append(ue) + continue + # update events with a location + Event.objects.bulk_update(to_be_updated, fields=["exact_location"]) + return len(to_be_updated) + + @classmethod + def get_all_cities(cls): + try: + tags = list( + [ + p["city"] + for p in Place.objects.values("city").distinct().order_by("city") + ] + ) + except Exception: + tags = [] + return tags + + +class ReferenceLocation(models.Model): + name = models.CharField( + verbose_name=_("Name"), + help_text=_("Name of the location"), + unique=True, + null=False, + ) + location = LocationField( + based_fields=["name"], + zoom=12, + default=Point(3.08333, 45.783329), + srid=4326, + ) + main = models.IntegerField( + verbose_name=_("Main"), + help_text=_( + "This location is one of the main locations (shown first higher values)." + ), + default=0, + ) + suggested_distance = models.IntegerField( + verbose_name=_("Suggested distance (km)"), + help_text=_( + "If this distance is given, this location is part of the suggested filters." + ), + null=True, + default=None, + ) + + slug = AutoSlugField(null=True, default=None, unique=True, populate_from="name") + + class Meta: + verbose_name = _("Reference location") + verbose_name_plural = _("Reference locations") + indexes = [ + models.Index(fields=["name"]), + ] + + def __str__(self): + return self.name diff --git a/src/agenda_culturel/models/special_period.py b/src/agenda_culturel/models/special_period.py new file mode 100644 index 0000000..d2c4925 --- /dev/null +++ b/src/agenda_culturel/models/special_period.py @@ -0,0 +1,109 @@ +from datetime import date + +import icalendar +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils.timezone import datetime +from django.utils.translation import gettext_lazy as _ + + +class SpecialPeriod(models.Model): + + name = models.CharField(verbose_name=_("Period name"), max_length=255) + start_date = models.DateField(verbose_name=_("Start day")) + end_date = models.DateField(verbose_name=_("End day")) + + class Meta: + verbose_name = _("Special period") + verbose_name_plural = _("Special periods") + indexes = [ + models.Index(fields=["start_date", "end_date"]), + ] + + class PERIODTYPE(models.TextChoices): + PUBLICHOLIDAYS = "public holidays", _("public holidays") + SCHOOLVACATIONS = "school vacations", _("school vacations") + + periodtype = models.CharField( + _("Period type"), + max_length=20, + choices=PERIODTYPE.choices, + default=PERIODTYPE.PUBLICHOLIDAYS, + ) + + def clean(self): + # Call the parent class's clean() method + super().clean() + + # Check that the end date is after or equal to the start date + if self.end_date < self.start_date: + raise ValidationError( + { + "end_date": _( + "The end date must be after or equal to the start date." + ) + } + ) + + def __str__(self): + n = self.periodtype + ' "' + self.name + '"' + if self.start_date == self.end_date: + return n + _(" on ") + str(self.start_date) + else: + return ( + n + _(" from ") + str(self.start_date) + _(" to ") + str(self.end_date) + ) + + def load_from_ical(uploaded_file, periodtype): + # load file + try: + file_content = uploaded_file.read() + calendar = icalendar.Calendar.from_ical(file_content) + + nb_error = 0 + nb_overlap = 0 + periods = [] + periods_to_create = [] + + # extract events + for event in calendar.walk("VEVENT"): + try: + name = event.decoded("SUMMARY").decode() + r = event.decoded("DTSTART") + if isinstance(r, datetime): + start_date = r.date() + elif isinstance(r, date): + start_date = r + r = event.decoded("DTEND") + if isinstance(r, datetime): + end_date = r.date() + elif isinstance(r, date): + end_date = r + periods.append( + SpecialPeriod( + name=name, + periodtype=periodtype, + start_date=start_date, + end_date=end_date, + ) + ) + except Exception: + nb_error += 1 + + for p in periods: + overlap_exists = SpecialPeriod.objects.filter( + Q(periodtype=p.periodtype) + & Q(start_date__lte=p.end_date) + & Q(end_date__gte=p.start_date) + ).exists() + + if overlap_exists: + nb_overlap += 1 + else: + periods_to_create.append(p) + + SpecialPeriod.objects.bulk_create(periods_to_create) + return len(periods_to_create), nb_overlap, nb_error, None + except Exception as e: + return 0, 0, 0, str(e) diff --git a/src/agenda_culturel/models/static_content.py b/src/agenda_culturel/models/static_content.py new file mode 100644 index 0000000..b73feeb --- /dev/null +++ b/src/agenda_culturel/models/static_content.py @@ -0,0 +1,34 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_ckeditor_5.fields import CKEditor5Field + + +class StaticContent(models.Model): + name = models.CharField( + verbose_name=_("Name"), + help_text=_("Category name"), + max_length=512, + unique=True, + ) + text = CKEditor5Field( + verbose_name=_("Content"), + help_text=_("Text as shown to the visitors"), + blank=True, + ) + url_path = models.CharField( + verbose_name=_("URL path"), + help_text=_("URL path where the content is included."), + ) + + class Meta: + verbose_name = _("Static content") + verbose_name_plural = _("Static contents") + indexes = [ + models.Index(fields=["name"]), + ] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return self.url_path diff --git a/src/agenda_culturel/models/tag.py b/src/agenda_culturel/models/tag.py new file mode 100644 index 0000000..1e728c3 --- /dev/null +++ b/src/agenda_culturel/models/tag.py @@ -0,0 +1,158 @@ +import hashlib + +import emoji +from django.core.cache import cache +from django.db import models +from django.db.models import Q +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django_ckeditor_5.fields import CKEditor5Field + +from ..models import Event, no_slash_validator, remove_accents + + +class Tag(models.Model): + name = models.CharField( + verbose_name=_("Name"), + help_text=_("Tag name"), + max_length=512, + unique=True, + validators=[no_slash_validator], + ) + + description = CKEditor5Field( + verbose_name=_("Description"), + help_text=_("Description of the tag"), + blank=True, + null=True, + ) + + message = CKEditor5Field( + verbose_name=_("Message"), + help_text=_( + "Message displayed to the user on each event associated with this tag." + ), + blank=True, + null=True, + ) + + principal = models.BooleanField( + verbose_name=_("Principal"), + help_text=_( + "This tag is highlighted as a main tag for visitors, particularly in the filter." + ), + default=False, + ) + + in_excluded_suggestions = models.BooleanField( + verbose_name=_("In excluded suggestions"), + help_text=_("This tag will be part of the excluded suggestions."), + default=False, + ) + + in_included_suggestions = models.BooleanField( + verbose_name=_("In included suggestions"), + help_text=_("This tag will be part of the included suggestions."), + default=False, + ) + + class Meta: + verbose_name = _("Tag") + verbose_name_plural = _("Tags") + indexes = [ + models.Index(fields=["name"]), + ] + + def get_absolute_url(self): + return reverse("view_tag", kwargs={"t": self.name}) + + @classmethod + def clear_cache(cls): + for exclude in [False, True]: + for include in [False, True]: + for nb_suggestions in [10]: + id_cache = ( + "all_tags " + + str(exclude) + + " " + + str(include) + + " " + + str(nb_suggestions) + ) + id_cache = hashlib.md5(id_cache.encode("utf8")).hexdigest() + cache.delete(id_cache) + + def get_tag_groups(nb_suggestions=10, exclude=False, include=False, all=False): + id_cache = ( + "all_tags " + str(exclude) + " " + str(include) + " " + str(nb_suggestions) + ) + id_cache = hashlib.md5(id_cache.encode("utf8")).hexdigest() + result = cache.get(id_cache) + + if not result: + free_tags = Event.get_all_tags(False) + f_tags = [t["tag"] for t in free_tags] + + obj_tags = Tag.objects + + if all: + obj_tags = obj_tags.filter( + Q(in_excluded_suggestions=True) + | Q(in_included_suggestions=True) + | Q(principal=True) + ) + else: + if exclude: + obj_tags = obj_tags.filter(Q(in_excluded_suggestions=True)) + if include: + obj_tags = obj_tags.filter( + Q(in_included_suggestions=True) | Q(principal=True) + ) + + if not exclude and not include: + obj_tags = obj_tags.filter(principal=True) + + obj_tags = obj_tags.values_list("name", flat=True) + + if len(obj_tags) > nb_suggestions: + nb_suggestions = len(obj_tags) + + tags = [ + { + "tag": t["tag"], + "count": 1000000 if t["tag"] in obj_tags else t["count"], + } + for t in free_tags + ] + tags += [ + {"tag": o, "count": 0} + for o in Tag.objects.filter(~Q(name__in=f_tags)).values_list( + "name", flat=True + ) + ] + + tags.sort(key=lambda x: -x["count"]) + + tags1 = tags[0:nb_suggestions] + tags1.sort( + key=lambda x: emoji.demojize( + remove_accents(x["tag"]).lower(), delimiters=("000", "") + ) + ) + tags2 = tags[nb_suggestions:] + tags2.sort( + key=lambda x: emoji.demojize( + remove_accents(x["tag"]).lower(), delimiters=("000", "") + ) + ) + + result = ( + (_("Suggestions"), [(t["tag"], t["tag"]) for t in tags1]), + (_("Others"), [(t["tag"], t["tag"]) for t in tags2]), + ) + + cache.set(id_cache, result, 3000) # 50mn + return result + + def __str__(self): + return self.name diff --git a/src/agenda_culturel/models/user.py b/src/agenda_culturel/models/user.py new file mode 100644 index 0000000..3733194 --- /dev/null +++ b/src/agenda_culturel/models/user.py @@ -0,0 +1,36 @@ +from django.contrib.auth.models import User +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ + + +class UserProfile(models.Model): + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + primary_key=True, + ) + + is_moderation_expert = models.BooleanField( + verbose_name=_("Expert moderation user"), + help_text=_( + "This user is an expert in moderation, and the interface features additional functionalities." + ), + default=False, + ) + + class Meta: + verbose_name = _("User profile") + verbose_name_plural = _("User profiles") + + def __str__(self): + return _("User profile") + " (" + self.user.username + ")" + + +@receiver(post_save, sender=User) +def update_profile_signal(sender, instance, created, **kwargs): + if not hasattr(instance, "userprofile"): + UserProfile.objects.create(user=instance) + + instance.userprofile.save() diff --git a/src/agenda_culturel/models/utils.py b/src/agenda_culturel/models/utils.py new file mode 100644 index 0000000..69defe4 --- /dev/null +++ b/src/agenda_culturel/models/utils.py @@ -0,0 +1,19 @@ +import unicodedata + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +# Useful for translation +to_be_translated = [_("mean"), _("median"), _("maximum"), _("minimum"), _("stdev")] + + +def no_slash_validator(value): + if "/" in value: + raise ValidationError(_("The '/' character is not allowed.")) + + +def remove_accents(input_str): + if input_str is None: + return None + nfkd_form = unicodedata.normalize("NFKD", input_str) + return "".join([c for c in nfkd_form if not unicodedata.combining(c)]) From 59f3341b0dbe4c1542a29dc3d8b99c5ff7a650be Mon Sep 17 00:00:00 2001 From: SebF Date: Tue, 29 Apr 2025 11:49:42 +0200 Subject: [PATCH 2/3] =?UTF-8?q?WIP=20r=C3=A9f=C3=A9rence=20circulaire=20r?= =?UTF-8?q?=C3=A9solue=20pour=20les=20models.=20Probl=C3=A8me=20d=E2=80=99?= =?UTF-8?q?appel=20aux=20m=C3=A9thodes=20de=20classes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agenda_culturel/models/__init__.py | 8 +- src/agenda_culturel/models/category.py | 4 +- src/agenda_culturel/models/event.py | 33 ++++---- src/agenda_culturel/models/import_batch.py | 69 ----------------- .../{import_recurrent.py => imports.py} | 75 ++++++++++++++++++- src/agenda_culturel/models/message.py | 5 +- src/agenda_culturel/models/organisation.py | 4 +- src/agenda_culturel/models/place.py | 3 +- src/agenda_culturel/models/tag.py | 5 +- 9 files changed, 107 insertions(+), 99 deletions(-) delete mode 100644 src/agenda_culturel/models/import_batch.py rename src/agenda_culturel/models/{import_recurrent.py => imports.py} (70%) diff --git a/src/agenda_culturel/models/__init__.py b/src/agenda_culturel/models/__init__.py index ba6f09d..f7954bc 100644 --- a/src/agenda_culturel/models/__init__.py +++ b/src/agenda_culturel/models/__init__.py @@ -2,12 +2,12 @@ from .utils import * from .configuration import * from .user import * from .static_content import * + +from .category import * from .tag import * +from .event import * from .place import * from .organisation import * -from .event import * from .message import * -from .import_recurrent import * -from .import_batch import * -from .category import * +from .imports import * from .special_period import * diff --git a/src/agenda_culturel/models/category.py b/src/agenda_culturel/models/category.py index 9fdd695..748dfa8 100644 --- a/src/agenda_culturel/models/category.py +++ b/src/agenda_culturel/models/category.py @@ -6,7 +6,7 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from ..models import Place, remove_accents +from ..models.utils import remove_accents class Category(models.Model): @@ -166,7 +166,7 @@ class CategorisationRule(models.Model): ) place = models.ForeignKey( - Place, + "Place", verbose_name=_("Place"), help_text=_("Location from place"), null=True, diff --git a/src/agenda_culturel/models/event.py b/src/agenda_culturel/models/event.py index 0898256..95b51d0 100644 --- a/src/agenda_culturel/models/event.py +++ b/src/agenda_culturel/models/event.py @@ -33,20 +33,25 @@ from django_resized import ResizedImageField from icalendar import Calendar as icalCal from icalendar import Event as icalEvent -from ..models import ( - Category, - Place, - Organisation, - Message, - RecurrentImport, - remove_accents, - CategorisationRule, - SiteConfiguration, - Tag, -) +# from ..models.place import Place +# from ..models.category import CategorisationRule, Category +# from ..models.message import Message +# from ..models.imports import RecurrentImport +from ..models.utils import remove_accents +from ..models.configuration import SiteConfiguration + +# from ..models.tag import Tag from ..calendar import CalendarDay from ..import_tasks.extractor import Extractor +Category = None +CategorisationRule = None +Place = None +Tag = None +Message = None +RecurrentImport = None + + logger = logging.getLogger(__name__) @@ -272,7 +277,7 @@ class Event(models.Model): ) category = models.ForeignKey( - Category, + "Category", verbose_name=_("Category"), null=True, default=None, @@ -301,7 +306,7 @@ class Event(models.Model): ) exact_location = models.ForeignKey( - Place, + "Place", verbose_name=_("Location"), null=True, on_delete=models.SET_NULL, @@ -335,7 +340,7 @@ class Event(models.Model): ) organisers = models.ManyToManyField( - Organisation, + "Organisation", related_name="organised_events", verbose_name=_("Organisers"), help_text=_( diff --git a/src/agenda_culturel/models/import_batch.py b/src/agenda_culturel/models/import_batch.py deleted file mode 100644 index 013afc1..0000000 --- a/src/agenda_culturel/models/import_batch.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ - -from ..models import RecurrentImport - - -class BatchImportation(models.Model): - class STATUS(models.TextChoices): - RUNNING = "running", _("Running") - CANCELED = "canceled", _("Canceled") - SUCCESS = "success", _("Success") - FAILED = "failed", _("Failed") - - class Meta: - verbose_name = _("Batch importation") - verbose_name_plural = _("Batch importations") - permissions = [("run_batchimportation", "Can run a batch importation")] - indexes = [ - models.Index(fields=["created_date"]), - models.Index(fields=["status"]), - models.Index(fields=["created_date", "recurrentImport"]), - ] - - created_date = models.DateTimeField(auto_now_add=True) - - recurrentImport = models.ForeignKey( - RecurrentImport, - verbose_name=_("Recurrent import"), - help_text=_("Reference to the recurrent import processing"), - blank=True, - null=True, - on_delete=models.SET_NULL, - editable=False, - ) - - url_source = models.URLField( - verbose_name=_("URL (if not recurrent import)"), - help_text=_("Source URL if no RecurrentImport is associated."), - max_length=1024, - blank=True, - null=True, - editable=False, - ) - - status = models.CharField( - _("Status"), - max_length=20, - choices=STATUS.choices, - default=STATUS.RUNNING, - ) - - error_message = models.CharField( - verbose_name=_("Error message"), max_length=512, blank=True, null=True - ) - - nb_initial = models.PositiveIntegerField( - verbose_name=_("Number of collected events"), default=0 - ) - nb_imported = models.PositiveIntegerField( - verbose_name=_("Number of imported events"), default=0 - ) - nb_updated = models.PositiveIntegerField( - verbose_name=_("Number of updated events"), default=0 - ) - nb_removed = models.PositiveIntegerField( - verbose_name=_("Number of removed events"), default=0 - ) - - celery_id = models.CharField(max_length=128, default="") diff --git a/src/agenda_culturel/models/import_recurrent.py b/src/agenda_culturel/models/imports.py similarity index 70% rename from src/agenda_culturel/models/import_recurrent.py rename to src/agenda_culturel/models/imports.py index b3d5311..94b1b26 100644 --- a/src/agenda_culturel/models/import_recurrent.py +++ b/src/agenda_culturel/models/imports.py @@ -3,7 +3,11 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_better_admin_arrayfield.models.fields import ArrayField -from ..models import Organisation, Category, BatchImportation, Event + +# from ..models.event import Event +Event = None +# from ..models.organisation import Organisation +Organisation = None class RecurrentImport(models.Model): @@ -117,7 +121,7 @@ class RecurrentImport(models.Model): ) defaultOrganiser = models.ForeignKey( - Organisation, + "Organisation", verbose_name=_("Organiser"), help_text=_("Organiser of each imported event"), default=None, @@ -127,7 +131,7 @@ class RecurrentImport(models.Model): ) defaultCategory = models.ForeignKey( - Category, + "Category", verbose_name=_("Category"), help_text=_("Category of each imported event"), default=None, @@ -163,3 +167,68 @@ class RecurrentImport(models.Model): return events[0] else: return None + + +class BatchImportation(models.Model): + class STATUS(models.TextChoices): + RUNNING = "running", _("Running") + CANCELED = "canceled", _("Canceled") + SUCCESS = "success", _("Success") + FAILED = "failed", _("Failed") + + class Meta: + verbose_name = _("Batch importation") + verbose_name_plural = _("Batch importations") + permissions = [("run_batchimportation", "Can run a batch importation")] + indexes = [ + models.Index(fields=["created_date"]), + models.Index(fields=["status"]), + models.Index(fields=["created_date", "recurrentImport"]), + ] + + created_date = models.DateTimeField(auto_now_add=True) + + recurrentImport = models.ForeignKey( + RecurrentImport, + verbose_name=_("Recurrent import"), + help_text=_("Reference to the recurrent import processing"), + blank=True, + null=True, + on_delete=models.SET_NULL, + editable=False, + ) + + url_source = models.URLField( + verbose_name=_("URL (if not recurrent import)"), + help_text=_("Source URL if no RecurrentImport is associated."), + max_length=1024, + blank=True, + null=True, + editable=False, + ) + + status = models.CharField( + _("Status"), + max_length=20, + choices=STATUS.choices, + default=STATUS.RUNNING, + ) + + error_message = models.CharField( + verbose_name=_("Error message"), max_length=512, blank=True, null=True + ) + + nb_initial = models.PositiveIntegerField( + verbose_name=_("Number of collected events"), default=0 + ) + nb_imported = models.PositiveIntegerField( + verbose_name=_("Number of imported events"), default=0 + ) + nb_updated = models.PositiveIntegerField( + verbose_name=_("Number of updated events"), default=0 + ) + nb_removed = models.PositiveIntegerField( + verbose_name=_("Number of removed events"), default=0 + ) + + celery_id = models.CharField(max_length=128, default="") diff --git a/src/agenda_culturel/models/message.py b/src/agenda_culturel/models/message.py index 211e079..8640cd3 100644 --- a/src/agenda_culturel/models/message.py +++ b/src/agenda_culturel/models/message.py @@ -5,7 +5,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_ckeditor_5.fields import CKEditor5Field -from ..models import Event +# from ..models.event import Event +Event = None class Message(models.Model): @@ -38,7 +39,7 @@ class Message(models.Model): ) related_event = models.ForeignKey( - Event, + "Event", verbose_name=_("Related event"), help_text=_("The message is associated with this event."), null=True, diff --git a/src/agenda_culturel/models/organisation.py b/src/agenda_culturel/models/organisation.py index 85d2ea4..e077c27 100644 --- a/src/agenda_culturel/models/organisation.py +++ b/src/agenda_culturel/models/organisation.py @@ -3,8 +3,6 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_ckeditor_5.fields import CKEditor5Field -from ..models import Place - class Organisation(models.Model): name = models.CharField( @@ -31,7 +29,7 @@ class Organisation(models.Model): ) principal_place = models.ForeignKey( - Place, + "Place", verbose_name=_("Principal place"), help_text=_( "Place mainly associated with this organizer. Mainly used if there is a similarity in the name, to avoid redundant displays." diff --git a/src/agenda_culturel/models/place.py b/src/agenda_culturel/models/place.py index 8d84f63..25b5792 100644 --- a/src/agenda_culturel/models/place.py +++ b/src/agenda_culturel/models/place.py @@ -10,7 +10,8 @@ from django_ckeditor_5.fields import CKEditor5Field from django_extensions.db.fields import AutoSlugField from location_field.models.spatial import LocationField -from ..models import Event +# from ..models.event import Event +Event = None class Place(models.Model): diff --git a/src/agenda_culturel/models/tag.py b/src/agenda_culturel/models/tag.py index 1e728c3..e6cd821 100644 --- a/src/agenda_culturel/models/tag.py +++ b/src/agenda_culturel/models/tag.py @@ -8,7 +8,10 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_ckeditor_5.fields import CKEditor5Field -from ..models import Event, no_slash_validator, remove_accents +# from ..models.event import Event +from ..models.utils import no_slash_validator, remove_accents + +Event = None class Tag(models.Model): From 2f002014eb7441291a6366470e4634a77be0c28c Mon Sep 17 00:00:00 2001 From: SebF Date: Tue, 29 Apr 2025 21:44:24 +0200 Subject: [PATCH 3/3] =?UTF-8?q?appel=20aux=20m=C3=A9thodes=20de=20classes?= =?UTF-8?q?=20r=C3=A9solu=20par=20import=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agenda_culturel/models/event.py | 31 +++++++++++++++------------ src/agenda_culturel/models/imports.py | 8 ++----- src/agenda_culturel/models/message.py | 3 --- src/agenda_culturel/models/place.py | 9 +++++--- src/agenda_culturel/models/tag.py | 5 ++--- 5 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/agenda_culturel/models/event.py b/src/agenda_culturel/models/event.py index 95b51d0..e12d832 100644 --- a/src/agenda_culturel/models/event.py +++ b/src/agenda_culturel/models/event.py @@ -33,24 +33,11 @@ from django_resized import ResizedImageField from icalendar import Calendar as icalCal from icalendar import Event as icalEvent -# from ..models.place import Place -# from ..models.category import CategorisationRule, Category -# from ..models.message import Message -# from ..models.imports import RecurrentImport from ..models.utils import remove_accents from ..models.configuration import SiteConfiguration - -# from ..models.tag import Tag from ..calendar import CalendarDay from ..import_tasks.extractor import Extractor -Category = None -CategorisationRule = None -Place = None -Tag = None -Message = None -RecurrentImport = None - logger = logging.getLogger(__name__) @@ -187,6 +174,8 @@ class DuplicatedEvents(models.Model): super().save(*args, **kwargs) def get_import_messages(self): + from . import Message + msgs = [] for e in self.get_duplicated(): for m in e.message_set.filter( @@ -415,6 +404,8 @@ class Event(models.Model): self._messages = [] def get_import_messages(self): + from . import Message + return self.message_set.filter( message_type__in=[ Message.TYPE.IMPORT_PROCESS, @@ -501,6 +492,8 @@ class Event(models.Model): ) def in_recurrent_import(self): + from . import RecurrentImport + if self.import_sources: for s in self.import_sources: o_s = RecurrentImport.objects.filter(source=s).count() @@ -509,6 +502,8 @@ class Event(models.Model): return False def get_import_sources(self): + from . import RecurrentImport + if self.import_sources: result = [] for s in self.import_sources: @@ -741,6 +736,8 @@ class Event(models.Model): return result def tag_messages(self): + from ..models.tag import Tag + if self.tags is None: return [] return Tag.objects.filter( @@ -1143,8 +1140,10 @@ class Event(models.Model): self.recurrence_dtend = self.recurrence_dtstart def prepare_save(self): - self.update_modification_dates() + from ..models.place import Place + from ..models.category import Category, CategorisationRule + self.update_modification_dates() self.update_recurrence_dtstartend() # clear cache @@ -1177,6 +1176,8 @@ class Event(models.Model): CategorisationRule.apply_rules(self) def get_contributor_message(self): + from . import Message + types = [ Message.TYPE.FROM_CONTRIBUTOR, Message.TYPE.FROM_CONTRIBUTOR_NO_MSG, @@ -1721,6 +1722,8 @@ class Event(models.Model): return cal def get_count_modification(when): + from . import RecurrentImport + start = datetime(when[0].year, when[0].month, when[0].day) end = start + timedelta(days=when[1]) diff --git a/src/agenda_culturel/models/imports.py b/src/agenda_culturel/models/imports.py index 94b1b26..652ea4c 100644 --- a/src/agenda_culturel/models/imports.py +++ b/src/agenda_culturel/models/imports.py @@ -4,12 +4,6 @@ from django.utils.translation import gettext_lazy as _ from django_better_admin_arrayfield.models.fields import ArrayField -# from ..models.event import Event -Event = None -# from ..models.organisation import Organisation -Organisation = None - - class RecurrentImport(models.Model): class Meta: verbose_name = _("Recurrent import") @@ -154,6 +148,8 @@ class RecurrentImport(models.Model): return BatchImportation.objects.filter(recurrentImport=self).count() def nb_events(self): + from ..models.event import Event + return Event.objects.filter(import_sources__contains=[self.source]).count() def get_absolute_url(self): diff --git a/src/agenda_culturel/models/message.py b/src/agenda_culturel/models/message.py index 8640cd3..d6f2ee2 100644 --- a/src/agenda_culturel/models/message.py +++ b/src/agenda_culturel/models/message.py @@ -5,9 +5,6 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_ckeditor_5.fields import CKEditor5Field -# from ..models.event import Event -Event = None - class Message(models.Model): class TYPE(models.TextChoices): diff --git a/src/agenda_culturel/models/place.py b/src/agenda_culturel/models/place.py index 25b5792..d5f5e62 100644 --- a/src/agenda_culturel/models/place.py +++ b/src/agenda_culturel/models/place.py @@ -10,9 +10,6 @@ from django_ckeditor_5.fields import CKEditor5Field from django_extensions.db.fields import AutoSlugField from location_field.models.spatial import LocationField -# from ..models.event import Event -Event = None - class Place(models.Model): name = models.CharField(verbose_name=_("Name"), help_text=_("Name of the place")) @@ -77,9 +74,13 @@ class Place(models.Model): ) def nb_events(self): + from ..models.event import Event + return Event.objects.filter(exact_location=self).count() def nb_events_future(self): + from ..models.event import Event + return ( Event.objects.filter(start_day__gte=datetime.now()) .filter(exact_location=self) @@ -93,6 +94,8 @@ class Place(models.Model): return False def associate_matching_events(self): + from ..models.event import Event + u_events = Event.objects.filter(exact_location__isnull=True) to_be_updated = [] diff --git a/src/agenda_culturel/models/tag.py b/src/agenda_culturel/models/tag.py index e6cd821..9c6171c 100644 --- a/src/agenda_culturel/models/tag.py +++ b/src/agenda_culturel/models/tag.py @@ -8,11 +8,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_ckeditor_5.fields import CKEditor5Field -# from ..models.event import Event from ..models.utils import no_slash_validator, remove_accents -Event = None - class Tag(models.Model): name = models.CharField( @@ -93,6 +90,8 @@ class Tag(models.Model): result = cache.get(id_cache) if not result: + from ..models.event import Event + free_tags = Event.get_all_tags(False) f_tags = [t["tag"] for t in free_tags]