Merge branch 'main' into 415_Réorganiser_models

This commit is contained in:
Jean-Marie Favreau 2025-04-29 22:16:46 +02:00
commit d5618c8fab
26 changed files with 621 additions and 365 deletions

2
.gitignore vendored
View File

@ -52,6 +52,7 @@ cover/
*.mo
*.pot
*.log
*.log.*
local_settings.py
db.sqlite3
db.sqlite3-journal
@ -89,6 +90,7 @@ letsencrypt/
experimentations/cache/
experimentations/cache-augustes.ical
experimentations/events-augustes.json
logs-nginx
# MacOS
.DS_Store

View File

@ -84,3 +84,10 @@ Si la migration de la base de données s'est bien executée, mais qu'aucun évé
- `docker compose down --rmi all --volumes`
- `make build-dev`
### Projets similaires
* [hackeragenda](https://github.com/YoloSwagTeam/hackeragenda)
* [mobilizon](https://framagit.org/kaihuri/mobilizon)
* [koalagator](https://github.com/koalagator/koalagator)
* [gathio](https://gath.io/)

View File

@ -0,0 +1,18 @@
FROM nginx:latest
RUN apt-get update && \
apt-get install -y logrotate cron && \
rm -rf /var/lib/apt/lists/*
RUN rm -f /var/log/nginx/access.log /var/log/nginx/error.log
RUN chmod 755 /var/log/nginx
RUN chown root:nginx /var/log/nginx
COPY deployment/scripts/nginx/nginx.logrotate /etc/logrotate.d/nginx
RUN chmod 644 /etc/logrotate.d/nginx
COPY deployment/scripts/nginx/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]

View File

@ -12,5 +12,5 @@ else
python manage.py migrate --noinput
python manage.py collectstatic --noinput
python manage.py compilemessages
gunicorn "$APP_NAME".wsgi:application --bind "$APP_HOST":"$APP_PORT" --workers 3 --log-level=info
gunicorn "$APP_NAME".wsgi:application --bind "$APP_HOST":"$APP_PORT" --workers 5 --log-level=info
fi

View File

@ -0,0 +1,4 @@
#!/bin/bash
cron
nginx -g 'daemon off;'

View File

@ -8,6 +8,9 @@ http {
gzip on;
gzip_types text/plain text/css text/javascript;
log_format main 'remote_addr -remote_user [time_local] "request" '
'statusbody_bytes_sent "http_referer" '
'"http_user_agent" "$http_x_forwarded_for"';
upstream backend {
server backend:8000;
@ -35,9 +38,11 @@ http {
error_page 502 /static/html/502.html;
error_page 503 /static/html/503.html;
if ($http_user_agent ~* "Amazonbot|meta-externalagent|ClaudeBot|ahrefsbot|semrushbot") {
return 444;
}
if ($http_user_agent ~* "Amazonbot|meta-externalagent|ClaudeBot|ahrefsbot|semrushbot") {
return 444;
}
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
}
}

View File

@ -0,0 +1,14 @@
/var/log/nginx/*.log {
su root nginx
daily
missingok
rotate 7
compress
delaycompress
notifempty
create 640 root nginx
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 `cat /run/nginx.pid`
endscript
}

View File

@ -69,12 +69,15 @@ services:
command: [ "/bin/bash", "/app/deployment/scripts/wait-db.sh", "/app/deployment/scripts/celery/start-beat.sh" ]
nginx:
image: nginx:latest
container_name: "${APP_NAME}-nginx"
build:
context: .
dockerfile: deployment/Dockerfile-nginx
volumes:
- ./deployment/scripts/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- static_files:/usr/src/app/static
- media_files:/usr/src/app/media
- ./logs-nginx:/var/log/nginx
env_file: .env.prod
ports:
- 6380:80
@ -86,3 +89,4 @@ volumes:
media_files:
postgres_data_dir:
redis_data:
log_files:

View File

@ -24,19 +24,19 @@ from src.agenda_culturel.import_tasks.importer import URL2Events
if __name__ == "__main__":
u2e = URL2Events(SimpleDownloader(), mobilizon.CExtractor())
url = "https://mobilizon.fr/@attac63/events?"
url_human = "https://mobilizon.fr/@attac63/events"
url = "https://keskonfai.fr/events/166fca9c-e758-437c-8002-9a55d822e34d"
url_human = "https://keskonfai.fr/events/166fca9c-e758-437c-8002-9a55d822e34d"
try:
events = u2e.process(
url,
url_human,
cache="cache-attac63.html",
cache="cache-single-event-mobilizon.html",
default_values={},
published=True,
)
exportfile = "events-attac63.json"
exportfile = "events-single-event-mobilizon.json"
print("Saving events to file {}".format(exportfile))
with open(exportfile, "w") as f:
json.dump(events, f, indent=4, default=str)

View File

@ -458,10 +458,24 @@ def import_events_from_url(
from .db_importer import DBImporterEvents
if isinstance(urls, list):
url = urls[0]
is_list = True
if len(urls) > 0 and isinstance(urls[0], list):
url = urls[0][0][0]
cat = urls[0][0][1]
tags = urls[0][0][2]
if isinstance(tags, str):
tags = [tags]
user_id = urls[1]
email = urls[2]
comments = urls[3]
is_tuple = True
is_list = False
else:
url = urls[0]
is_list = True
is_tuple = False
else:
is_list = False
is_tuple = False
url = urls
with memcache_chromium_lock(self.app.oid) as acquired:
@ -489,7 +503,8 @@ def import_events_from_url(
try:
## create loader
u2e = URL2Events(ChromiumHeadlessDownloader(), single_event=True)
self.chromiumDownloader.pause = True
u2e = URL2Events(self.chromiumDownloader, single_event=True)
# set default values
values = {}
if cat is not None:
@ -528,7 +543,11 @@ def import_events_from_url(
logger.error(e)
close_import_task(self.request.id, False, e, importer)
return urls[1:] if is_list else True
return (
urls[1:]
if is_list
else [urls[0][1:], user_id, email, comments] if is_tuple else True
)
# if chromium is locked, we wait 30 seconds before retrying
raise self.retry(countdown=30)
@ -538,15 +557,20 @@ def import_events_from_url(
def import_events_from_urls(
self, urls_cat_tags, user_id=None, email=None, comments=None
):
for ucat in urls_cat_tags:
if ucat is not None:
url = ucat[0]
cat = ucat[1]
tags = ucat[2]
import_events_from_url.delay(
url, cat, tags, user_id=user_id, email=email, comments=comments
print("ça chaine baby")
# run tasks as a chain
tasks = chain(
(
import_events_from_url.s(
[urls_cat_tags, user_id, email, comments], force=True
)
if i == 0
else import_events_from_url.s(force=True)
)
for i in range(len(urls_cat_tags))
)
tasks.delay()
@app.task(base=ChromiumTask, bind=True)

View File

@ -555,8 +555,8 @@ class SimpleSearchEventFilter(django_filters.FilterSet):
Func(Lower("description"), function="unaccent"), value
),
),
relevance=F("rank") + F("similarity"),
).filter(Q(rank__gte=0.5) | Q(similarity__gte=0.3))
relevance=0.7 * F("rank") + 0.3 * F("similarity"),
).filter(Q(rank__gte=0.1) | Q(similarity__gte=0.3))
for f in [
"title",

View File

@ -537,13 +537,32 @@ class EventModerateForm(ModelForm):
class BatchImportationForm(Form):
required_css_class = "required"
json = CharField(
label="JSON",
data = CharField(
label=_("Data"),
widget=Textarea(attrs={"rows": "10"}),
help_text=_("JSON in the format expected for the import."),
help_text=_("Supported formats: json, html."),
required=True,
)
category = ModelChoiceField(
label=_("Category"),
queryset=Category.objects.all().order_by("name"),
help_text=_("Used only if data is html."),
initial=None,
required=False,
)
tags = MultipleChoiceField(
label=_("Tags"),
initial=None,
choices=[],
help_text=_("Used only if data is html."),
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["tags"].choices = Tag.get_tag_groups(all=True)
class FixDuplicates(Form):
required_css_class = "required"

View File

@ -190,6 +190,9 @@ class Extractor(ABC):
def is_known_url(url):
return False
def clean_url(url):
return url
def set_header(self, url):
self.header["url"] = url
self.header["date"] = datetime.now()
@ -312,6 +315,7 @@ class Extractor(ABC):
from .generic_extractors.ical import ICALExtractor
from .custom_extractors.associations_cf import CExtractor as AssociationsCF
from .generic_extractors.helloasso import CExtractor as HelloAssoExtractor
from .generic_extractors.mobilizon import CExtractor as MobilizonExtractor
if single_event:
return [
@ -320,6 +324,7 @@ class Extractor(ABC):
AssociationsCF,
ICALExtractor,
HelloAssoExtractor,
MobilizonExtractor,
EventNotFoundExtractor,
]
else:
@ -372,6 +377,3 @@ class EventNotFoundExtractor(Extractor):
)
return self.get_structure()
def clean_url(url):
return url

View File

@ -13,10 +13,50 @@ logger = logging.getLogger(__name__)
# A class dedicated to get events from Mobilizon
class CExtractor(Extractor):
event_params = """
id,
title,
url,
beginsOn,
endsOn,
options {
showStartTime,
showEndTime,
timezone
},
attributedTo {
avatar {
url,
}
name,
preferredUsername,
},
description,
onlineAddress,
physicalAddress {
locality,
description,
region
},
tags {
title,
id,
slug
},
picture {
url
},
status
"""
def __init__(self):
super().__init__()
self.no_downloader = True
def is_known_url(url, include_links=True):
u = urlparse(url)
return u.netloc in ["keskonfai.fr", "mobilizon.fr"]
# Source code adapted from https://framagit.org/Marc-AntoineA/mobilizon-client-python
def _request(self, body, data):
headers = {}
@ -57,49 +97,20 @@ query($preferredUsername: String!, $afterDatetime: DateTime) {
def _oncoming_events(self):
def _oncoming_events_page(page):
query = """
query = (
"""
query($preferredUsername: String!, $afterDatetime: DateTime, $page: Int) {
group(preferredUsername: $preferredUsername) {
organizedEvents(afterDatetime: $afterDatetime, page: $page) {
elements {
id,
title,
url,
beginsOn,
endsOn,
options {
showStartTime,
showEndTime,
timezone
},
attributedTo {
avatar {
url,
}
name,
preferredUsername,
},
description,
onlineAddress,
physicalAddress {
locality,
description,
region
},
tags {
title,
id,
slug
},
picture {
url
},
status
elements {"""
+ CExtractor.event_params
+ """
}
}
}
}
"""
)
today = datetime.now(timezone.utc).isoformat()
data = {
@ -119,6 +130,68 @@ query($preferredUsername: String!, $afterDatetime: DateTime, $page: Int) {
page += 1
return events
def _get_event(self):
query = (
"query GetEvent($uuid: UUID!) { event(uuid: $uuid) {"
+ CExtractor.event_params
+ "}}"
)
data = {
"uuid": self._uuid_event,
}
r = self._request(query, data)
return r["event"]
def add_mobilizon_event(self, e, default_values, published):
title = e["title"]
event_url = e["url"]
if "picture" in e and e["picture"] is not None:
image = e["picture"]["url"]
else:
image = None
location = (
e["physicalAddress"]["description"]
+ ", "
+ e["physicalAddress"]["locality"]
)
soup = BeautifulSoup(e["description"], "html.parser")
description = soup.get_text(separator="\n")
start = (
dateutil.parser.isoparse(e["beginsOn"])
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
)
end = (
dateutil.parser.isoparse(e["endsOn"])
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
)
start_day = start.date()
start_time = start.time() if e["options"]["showStartTime"] else None
end_day = end.date()
end_time = end.time() if e["options"]["showEndTime"] else None
self.add_event(
default_values,
title,
None,
start_day,
location,
description,
[],
uuids=[event_url],
recurrences=None,
url_human=event_url,
start_time=start_time,
published=published,
image=image,
end_day=end_day,
end_time=end_time,
)
def extract(
self,
content,
@ -145,52 +218,11 @@ query($preferredUsername: String!, $afterDatetime: DateTime, $page: Int) {
events = self._oncoming_events()
for e in events:
title = e["title"]
event_url = e["url"]
if "picture" in e and e["picture"] is not None:
image = e["picture"]["url"]
else:
image = None
location = (
e["physicalAddress"]["description"]
+ ", "
+ e["physicalAddress"]["locality"]
)
soup = BeautifulSoup(e["description"], "html.parser")
description = soup.get_text(separator="\n")
start = (
dateutil.parser.isoparse(e["beginsOn"])
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
)
end = (
dateutil.parser.isoparse(e["endsOn"])
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
)
start_day = start.date()
start_time = start.time() if e["options"]["showStartTime"] else None
end_day = end.date()
end_time = end.time() if e["options"]["showEndTime"] else None
self.add_event(
default_values,
title,
None,
start_day,
location,
description,
[],
uuids=[event_url],
recurrences=None,
url_human=event_url,
start_time=start_time,
published=published,
image=image,
end_day=end_day,
end_time=end_time,
)
self.add_mobilizon_event(e, default_values, published)
elif "events" in url:
self._api_end_point = "https://" + urlparse(url).netloc + "/api"
self._uuid_event = url.split("/")[-1]
event = self._get_event()
self.add_mobilizon_event(event, default_values, published)
return self.get_structure()

File diff suppressed because it is too large Load Diff

View File

@ -178,12 +178,7 @@ class DuplicatedEvents(models.Model):
msgs = []
for e in self.get_duplicated():
for m in e.message_set.filter(
message_type__in=[
Message.TYPE.IMPORT_PROCESS,
Message.TYPE.UPDATE_PROCESS,
]
).order_by("date"):
for m in e.message_set.order_by("date"):
msgs.append(m)
return msgs
@ -404,14 +399,7 @@ 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,
Message.TYPE.UPDATE_PROCESS,
]
).order_by("date")
return self.message_set.order_by("date")
def get_consolidated_end_day(self, intuitive=True):
if intuitive:
@ -593,8 +581,30 @@ class Event(models.Model):
output_field=models.IntegerField(),
)
)
with_emoji = [
s for s in self.tags if any(char in emoji.EMOJI_DATA for char in s)
]
if len(with_emoji) > 0:
qs = qs.annotate(
overlap_emoji_tags=RawSQL(
sql="ARRAY(select UNNEST(%s::text[]) INTERSECT select UNNEST(tags))",
params=(with_emoji,),
output_field=ArrayField(models.CharField(max_length=50)),
)
).annotate(
overlap_emoji_tags_count=Func(
F("overlap_emoji_tags"),
function="CARDINALITY",
output_field=models.IntegerField(),
)
)
else:
qs = qs.annotate(overlap_emoji_tags_count=Value(0))
else:
qs = qs.annotate(overlap_tags_count=Value(1))
qs = qs.annotate(
overlap_tags_count=Value(0), overlap_emoji_tags_count=Value(0)
)
if self.exact_location:
qs = (
@ -622,7 +632,8 @@ class Event(models.Model):
)
)
.annotate(
score=F("overlap_tags_count") * 30
score=F("overlap_tags_count") * 20
+ F("overlap_emoji_tags_count") * 40
+ F("similarity_title") * 2
+ F("similarity_description") * 10
+ 10 / (F("distance") + 1)

View File

@ -297,9 +297,11 @@ LOGGING = {
"disable_existing_loggers": False,
"handlers": {
"file": {
"class": "logging.handlers.RotatingFileHandler",
"level": level_debug,
"class": "logging.FileHandler",
"filename": "backend.log",
"maxBytes": 5 * 1024 * 1024, # 5 MB
"backupCount": 5, # keep last 5 files
},
"mail_admins": {
"level": "ERROR",

View File

@ -521,8 +521,9 @@ header .title {
}
.slider-button {
.slider-button a, .slider-button-inside {
@extend [role="button"];
color: var(--primary-inverse);
height: 1.8em;
width: 1.8em;
padding: 0;
@ -531,9 +532,6 @@ header .title {
border-radius: .9em;
line-height: 1.8em;
text-align: center;
a {
color: var(--primary-inverse);
}
}
.slider-button.hidden {
display: none;
@ -679,10 +677,12 @@ header .remarque {
display: inline-block;
}
.highlight {
rect.ch-subdomain-bg.highlight {
color: var(--primary);
font-weight: bold;
font-style: italic;
stroke-width: 2;
stroke: red !important;
}
.search .description {

View File

@ -4,12 +4,65 @@
{% block og_title %}Importation manuelle{% endblock %}
{% endblock %}
{% block content %}
<h1>Importation manuelle</h1>
<article>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Envoyer">
</form>
</article>
<div class="grid two-columns">
<article>
<header>
<h1>Importation manuelle</h1>
</header>
<p>
Le formulaire ci-dessous permet d'importer des événements par lot. Deux formats sont actuellement disponibles&nbsp;:
</p>
<ul>
<li>
<strong>Format json&nbsp;:</strong> format structuré tel qu'attendu par l'importateur. Voir <a href="https://forge.chapril.org/jmtrivial/agenda_culturel/wiki/Import-JSON">la documentation sur le wiki du projet</a>.
</li>
<li>
<strong>Format html&nbsp;:</strong> forme libre, où tous les liens qui sont considérés comme reconnus par l'<a href="{% url 'add_event' %}">outil d'import principal</a> seront importés.
Cette fonctionnalité peut être utile quand on veut importer plus que les 8 événements détectés par défaut lors d'un import Facebook. Pour cela, se rendre sur la page de la liste
d'événements, enregistrer le html de la page (clic droit, enregistrer sous), puis copier le contenu de ce fichier html ci-dessous.
</li>
</ul>
<p>
Les champs catégorie et étiquettes seront utilisés si on utilise le format html, pour catégoriser tous les événements qui seront importés.
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Envoyer">
</form>
</article>
{% include "agenda_culturel/side-nav.html" with current="manual-import" %}
</div>
<script src="{% static 'choicejs/choices.min.js' %}"></script>
<script>
show_firstgroup = {
choice(classes, choice) {
const i = Choices.defaults.templates.choice.call(this, classes, choice);
if (this.first_group !== null && choice.groupId == this.first_group)
i.classList.add("visible");
return i;
},
choiceGroup(classes, group) {
const g = Choices.defaults.templates.choiceGroup.call(this, classes, group);
if (this.first_group === undefined && group.value == "Suggestions")
this.first_group = group.id;
if (this.first_group !== null && group.id == this.first_group)
g.classList.add("visible");
return g;
}
};
const tags = document.querySelector('#id_tags');
const choices_tags = new Choices(tags,
{
placeholderValue: 'Sélectionner les étiquettes à ajouter',
allowHTML: true,
searchResultLimit: 10,
delimiter: ',',
removeItemButton: true,
shouldSort: false,
callbackOnCreateTemplates: () => (show_firstgroup)
}
);
</script>
{% endblock %}

View File

@ -76,7 +76,7 @@
choices.showDropdown();
setTimeout(() => {
const searchTerm = htmlDecode('{{ object.location }}');
const searchTerm = htmlDecode('{{ object.location }}').replace(/\(?\d{5}\)?/, '');
choices.input.focus();
choices.input.value = searchTerm;
choices._handleSearch(searchTerm);

View File

@ -11,7 +11,7 @@
{% block content %}
<article>
<header>
<h1>Importer un événement</h1>
<h1>Importer un événement</h1>
{% url 'event_import_url' as local_url %}
{% include "agenda_culturel/static_content.html" with name="import" url_path=local_url %}
</header>
@ -53,6 +53,7 @@
placeholderValue: 'Sélectionner les étiquettes à ajouter',
allowHTML: true,
delimiter: ',',
searchResultLimit: 10,
removeItemButton: true,
shouldSort: false,
callbackOnCreateTemplates: () => (show_firstgroup)

View File

@ -60,6 +60,7 @@
{
placeholderValue: 'Sélectionner les étiquettes à ajouter',
allowHTML: true,
searchResultLimit: 10,
delimiter: ',',
removeItemButton: true,
shouldSort: false,

View File

@ -70,6 +70,10 @@
<a {% if current == "imports" %}class="selected"{% endif %}
href="{% url 'imports' %}">Historiques des importations</a>
</li>
<li>
<a {% if current == "manual-import" %}class="selected"{% endif %}
href="{% url 'add_import' %}">Import manuel</a>
</li>
{% endif %}
{% if perms.agenda_culturel.view_recurrentimport %}
<li>

View File

@ -132,7 +132,14 @@
},
theme: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? "dark" : "light",
itemSelector: '#cal-heatmap-startday',
animationDuration: 0
animationDuration: 0,
scale: {
color: {
type: 'linear',
domain: [0, Math.max(...data_startday.map(d => d.value))],
range: ['#f0fefe', '#005e5e'],
},
}
};
// create first heatmap
@ -146,6 +153,12 @@
options.itemSelector = '#cal-heatmap-creation';
options.date.start = new Date("{{ first_by_creation.isoformat }}");
options.data.source = data_creation;
options.scale.color = {
type: 'linear',
domain: [0, Math.max(...data_creation.map(d => d.value))],
range: ['#fdf5f5', '#5e0000'],
};
const cal_creation = new CalHeatmap();
cal_creation.paint(options, [
[CalendarLabel, calendarlabel],

View File

@ -340,7 +340,7 @@ def import_event_proxy(request):
)
return HttpResponseRedirect(ex.get_absolute_url())
else:
messages.info(
messages.success(
request,
_(
"This type of address is known to the calendar, so an automatic import is proposed."

View File

@ -10,9 +10,12 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from ..celery import app as celery_app, update_orphan_pure_import_events
from ..celery import import_events_from_json
from ..celery import import_events_from_json, import_events_from_urls
from ..forms import BatchImportationForm
from ..models import Event, BatchImportation, RecurrentImport
from ..import_tasks.extractor import Extractor
from bs4 import BeautifulSoup
import json
@login_required(login_url="/accounts/login/")
@ -72,9 +75,38 @@ def add_import(request):
form = BatchImportationForm(request.POST)
if form.is_valid():
import_events_from_json.delay(form.data["json"])
try:
# try to load data as a json file
json.loads(form.data["data"])
# if data is a json, load it
import_events_from_json.delay(form.data["data"])
messages.success(
request, _("The import from json has been run successfully.")
)
except ValueError:
# otherwise, consider it as html, extract all urls, and import them
soup = BeautifulSoup(form.data["data"], "html.parser")
urls = list(
set(
[
a["href"]
for a in soup.find_all("a", href=True)
if Extractor.is_known_url_default_extractors(a["href"])
]
)
)
# then import events from url
import_events_from_urls.delay(
[(u, form.data["category"], form.data["tags"]) for u in urls],
user_id=request.user.pk if request.user else None,
)
messages.success(
request,
_(
"The import from html ({} detected links) has been run successfully."
).format(len(urls)),
)
messages.success(request, _("The import has been run successfully."))
return HttpResponseRedirect(reverse_lazy("imports"))
return render(request, "agenda_culturel/batchimportation_form.html", {"form": form})