Tag progetto, @menzioni, appuntamenti da conversazioni

- Modello Tag con nome e colore, M2M su Conversazione
- Modello Appuntamento con luogo, note, partecipanti, link a Conversazione
- @menzioni nei commenti e aggiornamenti: @username → link al profilo
- Autocomplete JS per @menzioni nelle textarea
- Auto-data conversazioni (default=now)
- CRUD completo appuntamenti con permessi autore
- Appuntamenti in agenda, dashboard, dettaglio conversazione
- Crea riunione direttamente da una conversazione (pre-compila titolo e partecipanti)
- Admin: Tag, Appuntamento registrati
This commit is contained in:
automationkriz
2026-04-07 14:28:47 +00:00
parent 006bb24215
commit 09f51b1227
19 changed files with 828 additions and 12 deletions

View File

@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento, Tag, Appuntamento
class AggiornamentoInline(admin.TabularInline):
@@ -31,9 +31,9 @@ class DocumentoObiettivoInline(admin.TabularInline):
@admin.register(Conversazione)
class ConversazioneAdmin(admin.ModelAdmin):
list_display = ('titolo', 'data', 'registrato_da')
list_filter = ('data', 'registrato_da')
list_filter = ('data', 'registrato_da', 'tags')
search_fields = ('titolo', 'contenuto')
filter_horizontal = ('partecipanti',)
filter_horizontal = ('partecipanti', 'tags')
readonly_fields = ('data',)
inlines = [CommentoConversazioneInline, DocumentoInline]
@@ -68,3 +68,23 @@ class DocumentoAdmin(admin.ModelAdmin):
search_fields = ('titolo', 'descrizione')
readonly_fields = ('data_caricamento',)
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ('nome', 'colore')
search_fields = ('nome',)
class AppuntamentoPartecipantiInline(admin.TabularInline):
model = Appuntamento.partecipanti.through
extra = 0
@admin.register(Appuntamento)
class AppuntamentoAdmin(admin.ModelAdmin):
list_display = ('titolo', 'data_ora', 'luogo', 'creato_da', 'conversazione')
list_filter = ('data_ora', 'creato_da')
search_fields = ('titolo', 'note', 'luogo')
filter_horizontal = ('partecipanti',)
readonly_fields = ('data_creazione',)

View File

@@ -1,7 +1,7 @@
from django import forms
from django.contrib.auth.models import User
from django.utils import timezone
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento, Tag, Appuntamento
class ConversazioneForm(forms.ModelForm):
@@ -20,10 +20,16 @@ class ConversazioneForm(forms.ModelForm):
required=False,
label='Partecipanti',
)
tags = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label='Tag progetto',
)
class Meta:
model = Conversazione
fields = ['titolo', 'data', 'contenuto', 'partecipanti']
fields = ['titolo', 'data', 'contenuto', 'partecipanti', 'tags']
widgets = {
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo della conversazione'}),
'contenuto': forms.Textarea(attrs={'class': 'form-control', 'rows': 6, 'placeholder': 'Descrivi cosa è stato discusso...'}),
@@ -94,3 +100,29 @@ class DocumentoForm(forms.ModelForm):
'conversazione': 'Collegato a conversazione (opzionale)',
'obiettivo': 'Collegato a obiettivo (opzionale)',
}
class AppuntamentoForm(forms.ModelForm):
data_ora = forms.DateTimeField(
label='Data e ora',
widget=forms.DateTimeInput(
attrs={'class': 'form-control', 'type': 'datetime-local'},
format='%Y-%m-%dT%H:%M',
),
input_formats=['%Y-%m-%dT%H:%M'],
)
partecipanti = forms.ModelMultipleChoiceField(
queryset=User.objects.filter(is_active=True).order_by('first_name', 'username'),
widget=forms.CheckboxSelectMultiple,
required=False,
label='Partecipanti',
)
class Meta:
model = Appuntamento
fields = ['titolo', 'data_ora', 'luogo', 'note', 'partecipanti']
widgets = {
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo appuntamento/riunione'}),
'luogo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Luogo (opzionale)'}),
'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Note (opzionale)'}),
}

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2.12 on 2026-04-07 14:27
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('diario', '0005_commento_conversazione'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nome', models.CharField(max_length=50, unique=True)),
('colore', models.CharField(default='#4361ee', max_length=7)),
],
options={
'verbose_name': 'Tag',
'verbose_name_plural': 'Tag',
'ordering': ['nome'],
},
),
migrations.AlterField(
model_name='conversazione',
name='data',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.CreateModel(
name='Appuntamento',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('titolo', models.CharField(max_length=200)),
('data_ora', models.DateTimeField()),
('luogo', models.CharField(blank=True, max_length=200)),
('note', models.TextField(blank=True)),
('data_creazione', models.DateTimeField(auto_now_add=True)),
('conversazione', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appuntamenti', to='diario.conversazione')),
('creato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appuntamenti_creati', to=settings.AUTH_USER_MODEL)),
('partecipanti', models.ManyToManyField(blank=True, related_name='appuntamenti', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Appuntamento',
'verbose_name_plural': 'Appuntamenti',
'ordering': ['data_ora'],
},
),
migrations.AddField(
model_name='conversazione',
name='tags',
field=models.ManyToManyField(blank=True, related_name='conversazioni', to='diario.tag'),
),
]

View File

@@ -11,14 +11,28 @@ def validate_file_size(value):
raise ValidationError('Il file non può superare i 10 MB.')
class Tag(models.Model):
nome = models.CharField(max_length=50, unique=True)
colore = models.CharField(max_length=7, default='#4361ee') # colore hex
class Meta:
ordering = ['nome']
verbose_name = 'Tag'
verbose_name_plural = 'Tag'
def __str__(self):
return self.nome
class Conversazione(models.Model):
titolo = models.CharField(max_length=200)
data = models.DateTimeField()
data = models.DateTimeField(default=timezone.now)
partecipanti = models.ManyToManyField(User, related_name='conversazioni_partecipate', blank=True)
contenuto = models.TextField()
registrato_da = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name='conversazioni_registrate'
)
tags = models.ManyToManyField(Tag, blank=True, related_name='conversazioni')
class Meta:
ordering = ['-data']
@@ -137,3 +151,30 @@ class Documento(models.Model):
import os
return os.path.basename(self.file.name)
class Appuntamento(models.Model):
titolo = models.CharField(max_length=200)
data_ora = models.DateTimeField()
luogo = models.CharField(max_length=200, blank=True)
note = models.TextField(blank=True)
conversazione = models.ForeignKey(
Conversazione, on_delete=models.SET_NULL, null=True, blank=True, related_name='appuntamenti'
)
partecipanti = models.ManyToManyField(User, blank=True, related_name='appuntamenti')
creato_da = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name='appuntamenti_creati'
)
data_creazione = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['data_ora']
verbose_name = 'Appuntamento'
verbose_name_plural = 'Appuntamenti'
def __str__(self):
return f"{self.titolo} ({self.data_ora.strftime('%d/%m/%Y %H:%M')})"
@property
def is_passato(self):
return self.data_ora < timezone.now()

View File

@@ -1,4 +1,9 @@
import re
from django import template
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.html import escape
register = template.Library()
@@ -10,3 +15,23 @@ def abs_val(value):
return abs(int(value))
except (ValueError, TypeError):
return value
@register.filter(needs_autoescape=True)
def render_mentions(text, autoescape=True):
"""Converte @username in link al profilo della persona."""
if autoescape:
text = escape(text)
def replace_mention(match):
username = match.group(1)
try:
user = User.objects.get(username=username, is_active=True)
url = reverse('persona_dettaglio', args=[user.pk])
nome = user.get_full_name() or user.username
return f'<a href="{url}" class="mention-link">@{escape(nome)}</a>'
except User.DoesNotExist:
return match.group(0)
result = re.sub(r'@(\w[\w.]+)', replace_mention, text)
return mark_safe(result)

View File

@@ -34,10 +34,20 @@ urlpatterns = [
path('documenti/<int:pk>/', views.documento_dettaglio, name='documento_dettaglio'),
path('documenti/<int:pk>/elimina/', views.documento_elimina, name='documento_elimina'),
# Appuntamenti
path('appuntamenti/', views.appuntamenti_lista, name='appuntamenti_lista'),
path('appuntamenti/nuovo/', views.appuntamento_nuovo, name='appuntamento_nuovo'),
path('appuntamenti/<int:pk>/', views.appuntamento_dettaglio, name='appuntamento_dettaglio'),
path('appuntamenti/<int:pk>/modifica/', views.appuntamento_modifica, name='appuntamento_modifica'),
path('appuntamenti/<int:pk>/elimina/', views.appuntamento_elimina, name='appuntamento_elimina'),
# Ricerca
path('ricerca/', views.ricerca, name='ricerca'),
# Persone
path('persone/', views.persone_lista, name='persone_lista'),
path('persone/<int:pk>/', views.persona_dettaglio, name='persona_dettaglio'),
# API
path('api/utenti/', views.api_utenti, name='api_utenti'),
]

View File

@@ -9,8 +9,8 @@ from django.utils import timezone
from itertools import chain
from datetime import timedelta
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
from .forms import ConversazioneForm, ObiettivoForm, AggiornamentoObiettivoForm, CommentoConversazioneForm, DocumentoForm
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento, Tag, Appuntamento
from .forms import ConversazioneForm, ObiettivoForm, AggiornamentoObiettivoForm, CommentoConversazioneForm, DocumentoForm, AppuntamentoForm
def _can_edit(user, obj):
@@ -51,11 +51,17 @@ def dashboard(request):
data_scadenza__lt=oggi,
).exclude(stato='completato').order_by('-data_scadenza')[:5]
# Prossimi appuntamenti
prossimi_appuntamenti = Appuntamento.objects.filter(
data_ora__gte=timezone.now(),
).select_related('creato_da', 'conversazione').order_by('data_ora')[:5]
return render(request, 'diario/dashboard.html', {
'eventi': eventi,
'obiettivi_aperti': obiettivi_aperti,
'scadenze_prossime': scadenze_prossime,
'scaduti': scaduti,
'prossimi_appuntamenti': prossimi_appuntamenti,
'oggi': oggi,
})
@@ -66,7 +72,7 @@ def dashboard(request):
def agenda(request):
oggi = timezone.now().date()
# Prossimi eventi: scadenze obiettivi, conversazioni programmate nel futuro
# Prossimi eventi: scadenze obiettivi, conversazioni programmate nel futuro, appuntamenti
scadenze_future = Obiettivo.objects.filter(
data_scadenza__gte=oggi,
).exclude(stato='completato').order_by('data_scadenza')
@@ -75,11 +81,16 @@ def agenda(request):
data__date__gte=oggi,
).select_related('registrato_da').order_by('data')
appuntamenti_futuri = Appuntamento.objects.filter(
data_ora__date__gte=oggi,
).select_related('creato_da', 'conversazione').order_by('data_ora')
# Eventi futuri unificati
eventi_futuri = sorted(
chain(
[{'tipo': 'scadenza', 'data': o.data_scadenza, 'obj': o} for o in scadenze_future],
[{'tipo': 'conversazione', 'data': c.data.date(), 'obj': c} for c in conversazioni_future],
[{'tipo': 'appuntamento', 'data': a.data_ora.date(), 'obj': a} for a in appuntamenti_futuri],
),
key=lambda x: x['data'],
)
@@ -96,10 +107,16 @@ def agenda(request):
data_scadenza__lt=oggi,
).order_by('-data_scadenza')
appuntamenti_passati = Appuntamento.objects.filter(
data_ora__date__gte=data_inizio,
data_ora__date__lt=oggi,
).select_related('creato_da', 'conversazione').order_by('-data_ora')
eventi_passati = sorted(
chain(
[{'tipo': 'scadenza', 'data': o.data_scadenza, 'obj': o, 'scaduto': o.stato != 'completato'} for o in scadenze_passate],
[{'tipo': 'conversazione', 'data': c.data.date(), 'obj': c, 'scaduto': False} for c in conversazioni_passate],
[{'tipo': 'appuntamento', 'data': a.data_ora.date(), 'obj': a, 'scaduto': False} for a in appuntamenti_passati],
),
key=lambda x: x['data'],
reverse=True,
@@ -141,6 +158,7 @@ def conversazione_dettaglio(request, pk):
conv = get_object_or_404(Conversazione, pk=pk)
commenti = conv.commenti.select_related('autore').order_by('-data')
documenti = conv.documenti.select_related('caricato_da').all()
appuntamenti = conv.appuntamenti.select_related('creato_da').all()
if request.method == 'POST':
comment_form = CommentoConversazioneForm(request.POST)
@@ -159,6 +177,7 @@ def conversazione_dettaglio(request, pk):
'commenti': commenti,
'comment_form': comment_form,
'documenti': documenti,
'appuntamenti': appuntamenti,
'can_edit': _can_edit(request.user, conv),
})
@@ -515,3 +534,114 @@ def persona_dettaglio(request, pk):
'documenti': documenti,
})
# ── Appuntamenti ───────────────────────────────────────────────────────────────
@login_required
def appuntamenti_lista(request):
oggi = timezone.now()
futuri = Appuntamento.objects.filter(data_ora__gte=oggi).select_related('creato_da', 'conversazione').order_by('data_ora')
passati = Appuntamento.objects.filter(data_ora__lt=oggi).select_related('creato_da', 'conversazione').order_by('-data_ora')[:30]
return render(request, 'diario/appuntamenti/lista.html', {
'futuri': futuri,
'passati': passati,
})
@login_required
def appuntamento_nuovo(request):
conversazione_pk = request.GET.get('conversazione')
initial = {}
if conversazione_pk:
conv = get_object_or_404(Conversazione, pk=conversazione_pk)
initial['titolo'] = f"Riunione: {conv.titolo}"
initial['partecipanti'] = conv.partecipanti.all()
if request.method == 'POST':
form = AppuntamentoForm(request.POST)
if form.is_valid():
app = form.save(commit=False)
app.creato_da = request.user
if conversazione_pk:
app.conversazione_id = conversazione_pk
app.save()
form.save_m2m()
messages.success(request, 'Appuntamento creato.')
if conversazione_pk:
return redirect('conversazione_dettaglio', pk=conversazione_pk)
return redirect('appuntamento_dettaglio', pk=app.pk)
else:
form = AppuntamentoForm(initial=initial)
return render(request, 'diario/appuntamenti/form.html', {
'form': form,
'titolo_pagina': 'Nuovo appuntamento',
'conversazione_pk': conversazione_pk,
})
@login_required
def appuntamento_dettaglio(request, pk):
app = get_object_or_404(Appuntamento.objects.select_related('creato_da', 'conversazione'), pk=pk)
return render(request, 'diario/appuntamenti/dettaglio.html', {
'app': app,
'can_edit': _can_edit(request.user, app),
})
@login_required
def appuntamento_modifica(request, pk):
app = get_object_or_404(Appuntamento, pk=pk)
if not _can_edit(request.user, app):
return HttpResponseForbidden('Non hai i permessi per modificare questo appuntamento.')
if request.method == 'POST':
form = AppuntamentoForm(request.POST, instance=app)
if form.is_valid():
form.save()
messages.success(request, 'Appuntamento aggiornato.')
return redirect('appuntamento_dettaglio', pk=app.pk)
else:
form = AppuntamentoForm(instance=app)
return render(request, 'diario/appuntamenti/form.html', {
'form': form,
'titolo_pagina': 'Modifica appuntamento',
'app': app,
})
@login_required
def appuntamento_elimina(request, pk):
app = get_object_or_404(Appuntamento, pk=pk)
if not _can_edit(request.user, app):
return HttpResponseForbidden('Non hai i permessi per eliminare questo appuntamento.')
if request.method == 'POST':
app.delete()
messages.success(request, 'Appuntamento eliminato.')
return redirect('appuntamenti_lista')
return render(request, 'diario/conferma_elimina.html', {
'oggetto': app,
'tipo': 'appuntamento',
'cancel_url': 'appuntamento_dettaglio',
})
# ── API: utenti per @menzioni ──────────────────────────────────────────────────
@login_required
def api_utenti(request):
"""Restituisce lista utenti per autocomplete @menzioni."""
q = request.GET.get('q', '').strip()
utenti = User.objects.filter(is_active=True)
if q:
utenti = utenti.filter(
Q(username__icontains=q) | Q(first_name__icontains=q) | Q(last_name__icontains=q)
)
data = [
{
'username': u.username,
'nome': u.get_full_name() or u.username,
'pk': u.pk,
}
for u in utenti.order_by('first_name', 'username')[:15]
]
return JsonResponse(data, safe=False)