WIP Séparation des models : problème de référence circulaire

This commit is contained in:
SebF 2025-04-27 12:19:54 +02:00
parent d74e1abd67
commit eb81c2438a
14 changed files with 1303 additions and 1210 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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