WIP Séparation des models : problème de référence circulaire
This commit is contained in:
parent
d74e1abd67
commit
eb81c2438a
13
src/agenda_culturel/models/__init__.py
Normal file
13
src/agenda_culturel/models/__init__.py
Normal 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 *
|
250
src/agenda_culturel/models/category.py
Normal file
250
src/agenda_culturel/models/category.py
Normal 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
|
89
src/agenda_culturel/models/configuration.py
Normal file
89
src/agenda_culturel/models/configuration.py
Normal 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")
|
File diff suppressed because it is too large
Load Diff
69
src/agenda_culturel/models/import_batch.py
Normal file
69
src/agenda_culturel/models/import_batch.py
Normal 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="")
|
165
src/agenda_culturel/models/import_recurrent.py
Normal file
165
src/agenda_culturel/models/import_recurrent.py
Normal 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
|
121
src/agenda_culturel/models/message.py
Normal file
121
src/agenda_culturel/models/message.py
Normal 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})
|
52
src/agenda_culturel/models/organisation.py
Normal file
52
src/agenda_culturel/models/organisation.py
Normal 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})
|
161
src/agenda_culturel/models/place.py
Normal file
161
src/agenda_culturel/models/place.py
Normal 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
|
109
src/agenda_culturel/models/special_period.py
Normal file
109
src/agenda_culturel/models/special_period.py
Normal 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)
|
34
src/agenda_culturel/models/static_content.py
Normal file
34
src/agenda_culturel/models/static_content.py
Normal 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
|
158
src/agenda_culturel/models/tag.py
Normal file
158
src/agenda_culturel/models/tag.py
Normal 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
|
36
src/agenda_culturel/models/user.py
Normal file
36
src/agenda_culturel/models/user.py
Normal 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()
|
19
src/agenda_culturel/models/utils.py
Normal file
19
src/agenda_culturel/models/utils.py
Normal 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)])
|
Loading…
x
Reference in New Issue
Block a user