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:
@@ -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',)
|
||||
|
||||
|
||||
@@ -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)'}),
|
||||
}
|
||||
|
||||
59
diario/migrations/0006_tag_appuntamento.py
Normal file
59
diario/migrations/0006_tag_appuntamento.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
136
diario/views.py
136
diario/views.py
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user