Initial commit: Diario Conversazioni Olimpic Nastri

- Django 5.2 + PostgreSQL + Gunicorn
- Conversazioni, Obiettivi, Documenti PDF, Persone
- Commenti e aggiornamenti con modifica/eliminazione
- Agenda, ricerca live, giorni rimanenti scadenze
- Bootstrap 5 + HTMX + toast notifications
- Deploy: Nginx + Gunicorn + SSL
This commit is contained in:
automationkriz
2026-04-05 14:48:22 +00:00
commit d296353dcb
48 changed files with 3538 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Virtual environment
nastrivenv/
# Database
db.sqlite3
# Media uploads
media/
# Backup
backup.json
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.pyc
# Static files collected
staticfiles/
# OS files
.DS_Store
*.swp
*.swo
*~

18
UTENTI.txt Normal file
View File

@@ -0,0 +1,18 @@
OLIMPIC PROGETTO NASTRI — Credenziali utenti
============================================
Nome Username Password
--------------------------+-----------------------+----------
Stefano Longhi stefano.longhi nastri2026
Roberto Bertocchi roberto.bertocchi nastri2026
Emanuele Noè emanuele.noe nastri2026
Francesco Tripaldi francesco.tripaldi nastri2026
Francesca Russo Cirillo francesca.russo nastri2026
Maurizio Fonda maurizio.fonda nastri2026
Renzo Sorci renzo.sorci nastri2026
Sandro Marega sandro.marega nastri2026
Stefania Bertocchi stefania.bertocchi nastri2026
Marco Tonsi marco.tonsi nastri2026
URL applicazione: https://diario.olimpic.click/
URL pannello admin: https://diario.olimpic.click/admin/

View File

@@ -0,0 +1,39 @@
server {
listen 80;
server_name diario.olimpic.click;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name diario.olimpic.click;
ssl_certificate /etc/letsencrypt/live/diario.olimpic.click/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/diario.olimpic.click/privkey.pem;
location /static/ {
alias /home/marco/olimpic_nastri/staticfiles/;
}
location /media/ {
alias /home/marco/olimpic_nastri/media/;
}
location / {
proxy_pass http://unix:/run/olimpic_nastri/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_log /var/log/nginx/diario_olimpic_error.log;
access_log /var/log/nginx/diario_olimpic_access.log;
}

0
diario/__init__.py Normal file
View File

70
diario/admin.py Normal file
View File

@@ -0,0 +1,70 @@
from django.contrib import admin
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
class AggiornamentoInline(admin.TabularInline):
model = AggiornamentoObiettivo
extra = 0
readonly_fields = ('data',)
class CommentoConversazioneInline(admin.TabularInline):
model = CommentoConversazione
extra = 0
readonly_fields = ('data',)
class DocumentoInline(admin.TabularInline):
model = Documento
extra = 0
readonly_fields = ('data_caricamento',)
fk_name = 'conversazione'
class DocumentoObiettivoInline(admin.TabularInline):
model = Documento
extra = 0
readonly_fields = ('data_caricamento',)
fk_name = 'obiettivo'
@admin.register(Conversazione)
class ConversazioneAdmin(admin.ModelAdmin):
list_display = ('titolo', 'data', 'registrato_da')
list_filter = ('data', 'registrato_da')
search_fields = ('titolo', 'contenuto')
filter_horizontal = ('partecipanti',)
readonly_fields = ('data',)
inlines = [CommentoConversazioneInline, DocumentoInline]
@admin.register(Obiettivo)
class ObiettivoAdmin(admin.ModelAdmin):
list_display = ('titolo', 'tipo', 'stato', 'data_scadenza', 'creato_da')
list_filter = ('tipo', 'stato', 'assegnato_a')
search_fields = ('titolo', 'descrizione')
readonly_fields = ('data_creazione',)
inlines = [AggiornamentoInline, DocumentoObiettivoInline]
@admin.register(AggiornamentoObiettivo)
class AggiornamentoObiettivoAdmin(admin.ModelAdmin):
list_display = ('obiettivo', 'autore', 'data')
list_filter = ('autore', 'data')
readonly_fields = ('data',)
@admin.register(CommentoConversazione)
class CommentoConversazioneAdmin(admin.ModelAdmin):
list_display = ('conversazione', 'autore', 'data')
list_filter = ('autore', 'data')
readonly_fields = ('data',)
@admin.register(Documento)
class DocumentoAdmin(admin.ModelAdmin):
list_display = ('titolo', 'caricato_da', 'data_caricamento', 'conversazione', 'obiettivo')
list_filter = ('caricato_da', 'data_caricamento')
search_fields = ('titolo', 'descrizione')
readonly_fields = ('data_caricamento',)

6
diario/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DiarioConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'diario'

96
diario/forms.py Normal file
View File

@@ -0,0 +1,96 @@
from django import forms
from django.contrib.auth.models import User
from django.utils import timezone
from .models import Conversazione, Obiettivo, AggiornamentoObiettivo, CommentoConversazione, Documento
class ConversazioneForm(forms.ModelForm):
data = forms.DateTimeField(
label='Data e ora',
initial=timezone.now,
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 = Conversazione
fields = ['titolo', 'data', 'contenuto', 'partecipanti']
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...'}),
}
labels = {
'titolo': 'Titolo',
'contenuto': 'Contenuto',
}
class ObiettivoForm(forms.ModelForm):
assegnato_a = forms.ModelMultipleChoiceField(
queryset=User.objects.filter(is_active=True).order_by('first_name', 'username'),
widget=forms.CheckboxSelectMultiple,
required=False,
label='Assegnato a',
)
class Meta:
model = Obiettivo
fields = ['titolo', 'descrizione', 'tipo', 'assegnato_a', 'stato', 'data_scadenza']
widgets = {
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo obiettivo'}),
'descrizione': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Descrizione (opzionale)'}),
'tipo': forms.Select(attrs={'class': 'form-select'}),
'stato': forms.Select(attrs={'class': 'form-select'}),
'data_scadenza': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
}
class AggiornamentoObiettivoForm(forms.ModelForm):
class Meta:
model = AggiornamentoObiettivo
fields = ['testo']
widgets = {
'testo': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Scrivi un aggiornamento...'}),
}
labels = {
'testo': 'Aggiornamento',
}
class CommentoConversazioneForm(forms.ModelForm):
class Meta:
model = CommentoConversazione
fields = ['testo']
widgets = {
'testo': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Scrivi un commento...'}),
}
labels = {
'testo': 'Commento',
}
class DocumentoForm(forms.ModelForm):
class Meta:
model = Documento
fields = ['titolo', 'descrizione', 'file', 'conversazione', 'obiettivo']
widgets = {
'titolo': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Titolo del documento'}),
'descrizione': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Descrizione (opzionale)'}),
'file': forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': '.pdf'}),
'conversazione': forms.Select(attrs={'class': 'form-select'}),
'obiettivo': forms.Select(attrs={'class': 'form-select'}),
}
labels = {
'file': 'File PDF',
'conversazione': 'Collegato a conversazione (opzionale)',
'obiettivo': 'Collegato a obiettivo (opzionale)',
}

View File

@@ -0,0 +1,67 @@
# Generated by Django 5.2.12 on 2026-04-01 12:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Conversazione',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('titolo', models.CharField(max_length=200)),
('data', models.DateTimeField(auto_now_add=True)),
('contenuto', models.TextField()),
('partecipanti', models.ManyToManyField(blank=True, related_name='conversazioni_partecipate', to=settings.AUTH_USER_MODEL)),
('registrato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversazioni_registrate', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Conversazione',
'verbose_name_plural': 'Conversazioni',
'ordering': ['-data'],
},
),
migrations.CreateModel(
name='Obiettivo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('titolo', models.CharField(max_length=200)),
('descrizione', models.TextField(blank=True)),
('tipo', models.CharField(choices=[('collettivo', 'Collettivo'), ('individuale', 'Individuale')], default='collettivo', max_length=20)),
('stato', models.CharField(choices=[('aperto', 'Aperto'), ('in_corso', 'In corso'), ('completato', 'Completato'), ('sospeso', 'Sospeso')], default='aperto', max_length=20)),
('data_scadenza', models.DateField(blank=True, null=True)),
('data_creazione', models.DateTimeField(auto_now_add=True)),
('assegnato_a', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='obiettivi_assegnati', to=settings.AUTH_USER_MODEL)),
('creato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='obiettivi_creati', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Obiettivo',
'verbose_name_plural': 'Obiettivi',
'ordering': ['-data_creazione'],
},
),
migrations.CreateModel(
name='AggiornamentoObiettivo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('testo', models.TextField()),
('data', models.DateTimeField(auto_now_add=True)),
('autore', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aggiornamenti', to=settings.AUTH_USER_MODEL)),
('obiettivo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aggiornamenti', to='diario.obiettivo')),
],
options={
'verbose_name': 'Aggiornamento',
'verbose_name_plural': 'Aggiornamenti',
'ordering': ['-data'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-04-01 13:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('diario', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='obiettivo',
name='avanzamento',
field=models.PositiveSmallIntegerField(default=0),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.12 on 2026-04-01 13:59
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('diario', '0002_obiettivo_avanzamento'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='conversazione',
name='data',
field=models.DateTimeField(),
),
migrations.RemoveField(
model_name='obiettivo',
name='assegnato_a',
),
migrations.AddField(
model_name='obiettivo',
name='assegnato_a',
field=models.ManyToManyField(blank=True, related_name='obiettivi_assegnati', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.12 on 2026-04-04 16:45
import diario.models
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('diario', '0003_alter_conversazione_data_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Documento',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='documenti/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf']), diario.models.validate_file_size])),
('titolo', models.CharField(max_length=200)),
('descrizione', models.TextField(blank=True)),
('data_caricamento', models.DateTimeField(auto_now_add=True)),
('caricato_da', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documenti_caricati', to=settings.AUTH_USER_MODEL)),
('conversazione', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documenti', to='diario.conversazione')),
('obiettivo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documenti', to='diario.obiettivo')),
],
options={
'verbose_name': 'Documento',
'verbose_name_plural': 'Documenti',
'ordering': ['-data_caricamento'],
},
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.12 on 2026-04-05 14:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('diario', '0004_documento'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CommentoConversazione',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('testo', models.TextField()),
('data', models.DateTimeField(auto_now_add=True)),
('autore', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='commenti_conversazione', to=settings.AUTH_USER_MODEL)),
('conversazione', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commenti', to='diario.conversazione')),
],
options={
'verbose_name': 'Commento',
'verbose_name_plural': 'Commenti',
'ordering': ['-data'],
},
),
]

View File

139
diario/models.py Normal file
View File

@@ -0,0 +1,139 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import FileExtensionValidator
from django.utils import timezone
def validate_file_size(value):
limit = 10 * 1024 * 1024 # 10 MB
if value.size > limit:
from django.core.exceptions import ValidationError
raise ValidationError('Il file non può superare i 10 MB.')
class Conversazione(models.Model):
titolo = models.CharField(max_length=200)
data = models.DateTimeField()
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'
)
class Meta:
ordering = ['-data']
verbose_name = 'Conversazione'
verbose_name_plural = 'Conversazioni'
def __str__(self):
return f"{self.titolo} ({self.data.strftime('%d/%m/%Y')})"
class Obiettivo(models.Model):
TIPO_CHOICES = [
('collettivo', 'Collettivo'),
('individuale', 'Individuale'),
]
STATO_CHOICES = [
('aperto', 'Aperto'),
('in_corso', 'In corso'),
('completato', 'Completato'),
('sospeso', 'Sospeso'),
]
titolo = models.CharField(max_length=200)
descrizione = models.TextField(blank=True)
avanzamento = models.PositiveSmallIntegerField(default=0) # 0-100
tipo = models.CharField(max_length=20, choices=TIPO_CHOICES, default='collettivo')
assegnato_a = models.ManyToManyField(
User, blank=True,
related_name='obiettivi_assegnati'
)
stato = models.CharField(max_length=20, choices=STATO_CHOICES, default='aperto')
data_scadenza = models.DateField(null=True, blank=True)
creato_da = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name='obiettivi_creati'
)
data_creazione = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-data_creazione']
verbose_name = 'Obiettivo'
verbose_name_plural = 'Obiettivi'
def __str__(self):
return self.titolo
@property
def giorni_rimanenti(self):
"""Restituisce i giorni rimanenti alla scadenza (None se non impostata)."""
if not self.data_scadenza:
return None
delta = self.data_scadenza - timezone.now().date()
return delta.days
class AggiornamentoObiettivo(models.Model):
obiettivo = models.ForeignKey(Obiettivo, on_delete=models.CASCADE, related_name='aggiornamenti')
testo = models.TextField()
autore = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='aggiornamenti')
data = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-data']
verbose_name = 'Aggiornamento'
verbose_name_plural = 'Aggiornamenti'
def __str__(self):
return f"Aggiornamento su '{self.obiettivo}' del {self.data.strftime('%d/%m/%Y')}"
class CommentoConversazione(models.Model):
conversazione = models.ForeignKey(Conversazione, on_delete=models.CASCADE, related_name='commenti')
testo = models.TextField()
autore = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='commenti_conversazione')
data = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-data']
verbose_name = 'Commento'
verbose_name_plural = 'Commenti'
def __str__(self):
return f"Commento su '{self.conversazione}' del {self.data.strftime('%d/%m/%Y')}"
class Documento(models.Model):
file = models.FileField(
upload_to='documenti/%Y/%m/',
validators=[
FileExtensionValidator(allowed_extensions=['pdf']),
validate_file_size,
],
)
titolo = models.CharField(max_length=200)
descrizione = models.TextField(blank=True)
caricato_da = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name='documenti_caricati'
)
data_caricamento = models.DateTimeField(auto_now_add=True)
conversazione = models.ForeignKey(
'Conversazione', on_delete=models.SET_NULL, null=True, blank=True, related_name='documenti'
)
obiettivo = models.ForeignKey(
'Obiettivo', on_delete=models.SET_NULL, null=True, blank=True, related_name='documenti'
)
class Meta:
ordering = ['-data_caricamento']
verbose_name = 'Documento'
verbose_name_plural = 'Documenti'
def __str__(self):
return self.titolo
@property
def filename(self):
import os
return os.path.basename(self.file.name)

View File

View File

@@ -0,0 +1,12 @@
from django import template
register = template.Library()
@register.filter
def abs_val(value):
"""Restituisce il valore assoluto."""
try:
return abs(int(value))
except (ValueError, TypeError):
return value

3
diario/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

43
diario/urls.py Normal file
View File

@@ -0,0 +1,43 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.dashboard, name='dashboard'),
path('agenda/', views.agenda, name='agenda'),
# Conversazioni
path('conversazioni/', views.conversazioni_lista, name='conversazioni_lista'),
path('conversazioni/nuova/', views.conversazione_nuova, name='conversazione_nuova'),
path('conversazioni/<int:pk>/', views.conversazione_dettaglio, name='conversazione_dettaglio'),
path('conversazioni/<int:pk>/modifica/', views.conversazione_modifica, name='conversazione_modifica'),
path('conversazioni/<int:pk>/elimina/', views.conversazione_elimina, name='conversazione_elimina'),
# Commenti conversazioni
path('commenti/<int:pk>/modifica/', views.commento_modifica, name='commento_modifica'),
path('commenti/<int:pk>/elimina/', views.commento_elimina, name='commento_elimina'),
# Aggiornamenti obiettivi
path('aggiornamenti/<int:pk>/modifica/', views.aggiornamento_modifica, name='aggiornamento_modifica'),
path('aggiornamenti/<int:pk>/elimina/', views.aggiornamento_elimina, name='aggiornamento_elimina'),
# Obiettivi
path('obiettivi/', views.obiettivi_lista, name='obiettivi_lista'),
path('obiettivi/nuovo/', views.obiettivo_nuovo, name='obiettivo_nuovo'),
path('obiettivi/<int:pk>/', views.obiettivo_dettaglio, name='obiettivo_dettaglio'),
path('obiettivi/<int:pk>/modifica/', views.obiettivo_modifica, name='obiettivo_modifica'),
path('obiettivi/<int:pk>/elimina/', views.obiettivo_elimina, name='obiettivo_elimina'),
path('obiettivi/<int:pk>/avanzamento/', views.obiettivo_avanzamento_ajax, name='obiettivo_avanzamento_ajax'),
# Documenti
path('documenti/', views.documenti_lista, name='documenti_lista'),
path('documenti/nuovo/', views.documento_nuovo, name='documento_nuovo'),
path('documenti/<int:pk>/', views.documento_dettaglio, name='documento_dettaglio'),
path('documenti/<int:pk>/elimina/', views.documento_elimina, name='documento_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'),
]

517
diario/views.py Normal file
View File

@@ -0,0 +1,517 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.contrib import messages
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.http import require_POST
from django.db.models import Q, Count
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
def _can_edit(user, obj):
"""Autore o superuser possono modificare/eliminare."""
owner = getattr(obj, 'registrato_da', None) or getattr(obj, 'creato_da', None) or getattr(obj, 'caricato_da', None) or getattr(obj, 'autore', None)
return user == owner or user.is_superuser
# ── Dashboard ──────────────────────────────────────────────────────────────────
@login_required
def dashboard(request):
conversazioni = Conversazione.objects.select_related('registrato_da').order_by('-data')[:20]
aggiornamenti = AggiornamentoObiettivo.objects.select_related('autore', 'obiettivo').order_by('-data')[:20]
documenti_recenti = Documento.objects.select_related('caricato_da').order_by('-data_caricamento')[:10]
eventi = sorted(
chain(
[{'tipo': 'conversazione', 'data': c.data, 'obj': c} for c in conversazioni],
[{'tipo': 'aggiornamento', 'data': a.data, 'obj': a} for a in aggiornamenti],
[{'tipo': 'documento', 'data': d.data_caricamento, 'obj': d} for d in documenti_recenti],
),
key=lambda x: x['data'],
reverse=True,
)[:30]
obiettivi_aperti = Obiettivo.objects.exclude(stato='completato').order_by('-data_creazione')[:5]
# Agenda: prossime scadenze obiettivi (prossimi 30 giorni)
oggi = timezone.now().date()
scadenze_prossime = Obiettivo.objects.filter(
data_scadenza__gte=oggi,
data_scadenza__lte=oggi + timedelta(days=30),
).exclude(stato='completato').order_by('data_scadenza')[:8]
# Scadenze passate non completate
scaduti = Obiettivo.objects.filter(
data_scadenza__lt=oggi,
).exclude(stato='completato').order_by('-data_scadenza')[:5]
return render(request, 'diario/dashboard.html', {
'eventi': eventi,
'obiettivi_aperti': obiettivi_aperti,
'scadenze_prossime': scadenze_prossime,
'scaduti': scaduti,
'oggi': oggi,
})
# ── Agenda ─────────────────────────────────────────────────────────────────────
@login_required
def agenda(request):
oggi = timezone.now().date()
# Prossimi eventi: scadenze obiettivi, conversazioni programmate nel futuro
scadenze_future = Obiettivo.objects.filter(
data_scadenza__gte=oggi,
).exclude(stato='completato').order_by('data_scadenza')
conversazioni_future = Conversazione.objects.filter(
data__date__gte=oggi,
).select_related('registrato_da').order_by('data')
# 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],
),
key=lambda x: x['data'],
)
# Eventi passati (ultimi 30 giorni)
data_inizio = oggi - timedelta(days=30)
conversazioni_passate = Conversazione.objects.filter(
data__date__gte=data_inizio,
data__date__lt=oggi,
).select_related('registrato_da').order_by('-data')
scadenze_passate = Obiettivo.objects.filter(
data_scadenza__gte=data_inizio,
data_scadenza__lt=oggi,
).order_by('-data_scadenza')
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],
),
key=lambda x: x['data'],
reverse=True,
)
return render(request, 'diario/agenda.html', {
'eventi_futuri': eventi_futuri,
'eventi_passati': eventi_passati,
'oggi': oggi,
})
# ── Conversazioni ──────────────────────────────────────────────────────────────
@login_required
def conversazioni_lista(request):
qs = Conversazione.objects.select_related('registrato_da').order_by('-data')
return render(request, 'diario/conversazioni/lista.html', {'conversazioni': qs})
@login_required
def conversazione_nuova(request):
if request.method == 'POST':
form = ConversazioneForm(request.POST)
if form.is_valid():
conv = form.save(commit=False)
conv.registrato_da = request.user
conv.save()
form.save_m2m()
messages.success(request, 'Conversazione registrata.')
return redirect('conversazione_dettaglio', pk=conv.pk)
else:
form = ConversazioneForm()
return render(request, 'diario/conversazioni/form.html', {'form': form, 'titolo_pagina': 'Nuova conversazione'})
@login_required
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()
if request.method == 'POST':
comment_form = CommentoConversazioneForm(request.POST)
if comment_form.is_valid():
c = comment_form.save(commit=False)
c.conversazione = conv
c.autore = request.user
c.save()
messages.success(request, 'Commento aggiunto.')
return redirect('conversazione_dettaglio', pk=conv.pk)
else:
comment_form = CommentoConversazioneForm()
return render(request, 'diario/conversazioni/dettaglio.html', {
'conv': conv,
'commenti': commenti,
'comment_form': comment_form,
'documenti': documenti,
'can_edit': _can_edit(request.user, conv),
})
@login_required
def conversazione_modifica(request, pk):
conv = get_object_or_404(Conversazione, pk=pk)
if not _can_edit(request.user, conv):
return HttpResponseForbidden('Non hai i permessi per modificare questa conversazione.')
if request.method == 'POST':
form = ConversazioneForm(request.POST, instance=conv)
if form.is_valid():
form.save()
messages.success(request, 'Conversazione aggiornata.')
return redirect('conversazione_dettaglio', pk=conv.pk)
else:
form = ConversazioneForm(instance=conv)
return render(request, 'diario/conversazioni/form.html', {'form': form, 'titolo_pagina': 'Modifica conversazione', 'conv': conv})
@login_required
def conversazione_elimina(request, pk):
conv = get_object_or_404(Conversazione, pk=pk)
if not _can_edit(request.user, conv):
return HttpResponseForbidden('Non hai i permessi per eliminare questa conversazione.')
if request.method == 'POST':
conv.delete()
messages.success(request, 'Conversazione eliminata.')
return redirect('conversazioni_lista')
return render(request, 'diario/conferma_elimina.html', {
'oggetto': conv,
'tipo': 'conversazione',
'cancel_url': 'conversazione_dettaglio',
})
# ── Commenti conversazione — modifica/elimina ─────────────────────────────────
@login_required
def commento_modifica(request, pk):
commento = get_object_or_404(CommentoConversazione, pk=pk)
if not _can_edit(request.user, commento):
return HttpResponseForbidden('Non puoi modificare questo commento.')
if request.method == 'POST':
form = CommentoConversazioneForm(request.POST, instance=commento)
if form.is_valid():
form.save()
messages.success(request, 'Commento aggiornato.')
return redirect('conversazione_dettaglio', pk=commento.conversazione.pk)
else:
form = CommentoConversazioneForm(instance=commento)
return render(request, 'diario/commento_modifica.html', {
'form': form,
'commento': commento,
'tipo': 'commento',
'back_url': redirect('conversazione_dettaglio', pk=commento.conversazione.pk).url,
})
@login_required
def commento_elimina(request, pk):
commento = get_object_or_404(CommentoConversazione, pk=pk)
if not _can_edit(request.user, commento):
return HttpResponseForbidden('Non puoi eliminare questo commento.')
conv_pk = commento.conversazione.pk
if request.method == 'POST':
commento.delete()
messages.success(request, 'Commento eliminato.')
return redirect('conversazione_dettaglio', pk=conv_pk)
return render(request, 'diario/conferma_elimina.html', {
'oggetto': commento,
'tipo': 'commento',
'cancel_url': 'conversazione_dettaglio',
'cancel_pk': conv_pk,
})
# ── Aggiornamenti obiettivo — modifica/elimina ────────────────────────────────
@login_required
def aggiornamento_modifica(request, pk):
agg = get_object_or_404(AggiornamentoObiettivo, pk=pk)
if not _can_edit(request.user, agg):
return HttpResponseForbidden('Non puoi modificare questo aggiornamento.')
if request.method == 'POST':
form = AggiornamentoObiettivoForm(request.POST, instance=agg)
if form.is_valid():
form.save()
messages.success(request, 'Aggiornamento modificato.')
return redirect('obiettivo_dettaglio', pk=agg.obiettivo.pk)
else:
form = AggiornamentoObiettivoForm(instance=agg)
return render(request, 'diario/commento_modifica.html', {
'form': form,
'commento': agg,
'tipo': 'aggiornamento',
'back_url': redirect('obiettivo_dettaglio', pk=agg.obiettivo.pk).url,
})
@login_required
def aggiornamento_elimina(request, pk):
agg = get_object_or_404(AggiornamentoObiettivo, pk=pk)
if not _can_edit(request.user, agg):
return HttpResponseForbidden('Non puoi eliminare questo aggiornamento.')
obj_pk = agg.obiettivo.pk
if request.method == 'POST':
agg.delete()
messages.success(request, 'Aggiornamento eliminato.')
return redirect('obiettivo_dettaglio', pk=obj_pk)
return render(request, 'diario/conferma_elimina.html', {
'oggetto': agg,
'tipo': 'aggiornamento',
'cancel_url': 'obiettivo_dettaglio',
'cancel_pk': obj_pk,
})
# ── Obiettivi ──────────────────────────────────────────────────────────────────
@login_required
def obiettivi_lista(request):
filtro = request.GET.get('filtro', 'tutti')
qs = Obiettivo.objects.prefetch_related('assegnato_a').select_related('creato_da')
if filtro == 'collettivi':
qs = qs.filter(tipo='collettivo')
elif filtro == 'individuali':
qs = qs.filter(tipo='individuale')
elif filtro == 'miei':
qs = qs.filter(assegnato_a=request.user)
return render(request, 'diario/obiettivi/lista.html', {'obiettivi': qs, 'filtro': filtro})
@login_required
def obiettivo_nuovo(request):
if request.method == 'POST':
form = ObiettivoForm(request.POST)
if form.is_valid():
obj = form.save(commit=False)
obj.creato_da = request.user
obj.save()
form.save_m2m()
messages.success(request, 'Obiettivo creato.')
return redirect('obiettivo_dettaglio', pk=obj.pk)
else:
form = ObiettivoForm()
return render(request, 'diario/obiettivi/form.html', {'form': form, 'titolo_pagina': 'Nuovo obiettivo'})
@login_required
def obiettivo_dettaglio(request, pk):
obj = get_object_or_404(Obiettivo, pk=pk)
aggiornamenti = obj.aggiornamenti.select_related('autore').order_by('-data')
documenti = obj.documenti.select_related('caricato_da').all()
if request.method == 'POST':
agg_form = AggiornamentoObiettivoForm(request.POST)
if agg_form.is_valid():
agg = agg_form.save(commit=False)
agg.obiettivo = obj
agg.autore = request.user
agg.save()
messages.success(request, 'Aggiornamento aggiunto.')
return redirect('obiettivo_dettaglio', pk=obj.pk)
else:
agg_form = AggiornamentoObiettivoForm()
return render(request, 'diario/obiettivi/dettaglio.html', {
'obj': obj,
'aggiornamenti': aggiornamenti,
'documenti': documenti,
'agg_form': agg_form,
'can_edit': _can_edit(request.user, obj),
})
@login_required
def obiettivo_modifica(request, pk):
obj = get_object_or_404(Obiettivo, pk=pk)
if not _can_edit(request.user, obj):
return HttpResponseForbidden('Non hai i permessi per modificare questo obiettivo.')
if request.method == 'POST':
form = ObiettivoForm(request.POST, instance=obj)
if form.is_valid():
form.save()
messages.success(request, 'Obiettivo aggiornato.')
return redirect('obiettivo_dettaglio', pk=obj.pk)
else:
form = ObiettivoForm(instance=obj)
return render(request, 'diario/obiettivi/form.html', {'form': form, 'titolo_pagina': 'Modifica obiettivo', 'obj': obj})
@login_required
def obiettivo_elimina(request, pk):
obj = get_object_or_404(Obiettivo, pk=pk)
if not _can_edit(request.user, obj):
return HttpResponseForbidden('Non hai i permessi per eliminare questo obiettivo.')
if request.method == 'POST':
obj.delete()
messages.success(request, 'Obiettivo eliminato.')
return redirect('obiettivi_lista')
return render(request, 'diario/conferma_elimina.html', {
'oggetto': obj,
'tipo': 'obiettivo',
'cancel_url': 'obiettivo_dettaglio',
})
@login_required
@require_POST
def obiettivo_avanzamento_ajax(request, pk):
obj = get_object_or_404(Obiettivo, pk=pk)
try:
valore = int(request.POST.get('avanzamento', 0))
if not 0 <= valore <= 100:
return JsonResponse({'ok': False, 'error': 'Valore fuori range'}, status=400)
obj.avanzamento = valore
obj.save(update_fields=['avanzamento'])
return JsonResponse({'ok': True, 'avanzamento': obj.avanzamento})
except (ValueError, TypeError):
return JsonResponse({'ok': False, 'error': 'Valore non valido'}, status=400)
# ── Documenti ──────────────────────────────────────────────────────────────────
@login_required
def documenti_lista(request):
qs = Documento.objects.select_related('caricato_da', 'conversazione', 'obiettivo').order_by('-data_caricamento')
return render(request, 'diario/documenti/lista.html', {'documenti': qs})
@login_required
def documento_nuovo(request):
conversazione_pk = request.GET.get('conversazione')
obiettivo_pk = request.GET.get('obiettivo')
initial = {}
if conversazione_pk:
initial['conversazione'] = conversazione_pk
if obiettivo_pk:
initial['obiettivo'] = obiettivo_pk
if request.method == 'POST':
form = DocumentoForm(request.POST, request.FILES)
if form.is_valid():
doc = form.save(commit=False)
doc.caricato_da = request.user
doc.save()
messages.success(request, 'Documento caricato.')
if doc.conversazione:
return redirect('conversazione_dettaglio', pk=doc.conversazione.pk)
if doc.obiettivo:
return redirect('obiettivo_dettaglio', pk=doc.obiettivo.pk)
return redirect('documento_dettaglio', pk=doc.pk)
else:
form = DocumentoForm(initial=initial)
return render(request, 'diario/documenti/form.html', {'form': form, 'titolo_pagina': 'Carica documento'})
@login_required
def documento_dettaglio(request, pk):
doc = get_object_or_404(Documento.objects.select_related('caricato_da', 'conversazione', 'obiettivo'), pk=pk)
return render(request, 'diario/documenti/dettaglio.html', {
'doc': doc,
'can_edit': _can_edit(request.user, doc),
})
@login_required
def documento_elimina(request, pk):
doc = get_object_or_404(Documento, pk=pk)
if not _can_edit(request.user, doc):
return HttpResponseForbidden('Non hai i permessi per eliminare questo documento.')
if request.method == 'POST':
doc.file.delete(save=False)
doc.delete()
messages.success(request, 'Documento eliminato.')
return redirect('documenti_lista')
return render(request, 'diario/conferma_elimina.html', {
'oggetto': doc,
'tipo': 'documento',
'cancel_url': 'documento_dettaglio',
})
# ── Ricerca ────────────────────────────────────────────────────────────────────
@login_required
def ricerca(request):
q = request.GET.get('q', '').strip()
risultati = {'conversazioni': [], 'obiettivi': [], 'documenti': [], 'persone': []}
total = 0
if len(q) >= 2:
risultati['conversazioni'] = Conversazione.objects.filter(
Q(titolo__icontains=q) | Q(contenuto__icontains=q)
).select_related('registrato_da')[:20]
risultati['obiettivi'] = Obiettivo.objects.filter(
Q(titolo__icontains=q) | Q(descrizione__icontains=q)
).select_related('creato_da')[:20]
risultati['documenti'] = Documento.objects.filter(
Q(titolo__icontains=q) | Q(descrizione__icontains=q)
).select_related('caricato_da')[:20]
risultati['persone'] = User.objects.filter(
Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(username__icontains=q),
is_active=True,
)[:20]
total = sum(len(v) if hasattr(v, '__len__') else v.count() for v in risultati.values())
ctx = {'q': q, 'risultati': risultati, 'total': total}
if getattr(request, 'htmx', False):
return render(request, 'diario/ricerca_risultati.html', ctx)
return render(request, 'diario/ricerca.html', ctx)
# ── Persone ────────────────────────────────────────────────────────────────────
@login_required
def persone_lista(request):
persone = User.objects.filter(is_active=True).annotate(
num_conversazioni=Count('conversazioni_registrate', distinct=True),
num_obiettivi=Count('obiettivi_creati', distinct=True),
num_documenti=Count('documenti_caricati', distinct=True),
).order_by('first_name', 'username')
return render(request, 'diario/persone/lista.html', {'persone': persone})
@login_required
def persona_dettaglio(request, pk):
persona = get_object_or_404(User, pk=pk, is_active=True)
conversazioni = Conversazione.objects.filter(
Q(registrato_da=persona) | Q(partecipanti=persona)
).distinct().select_related('registrato_da').order_by('-data')[:20]
obiettivi = Obiettivo.objects.filter(
Q(creato_da=persona) | Q(assegnato_a=persona)
).distinct().select_related('creato_da').prefetch_related('assegnato_a').order_by('-data_creazione')[:20]
documenti = Documento.objects.filter(
caricato_da=persona
).select_related('conversazione', 'obiettivo').order_by('-data_caricamento')[:20]
return render(request, 'diario/persone/dettaglio.html', {
'persona': persona,
'conversazioni': conversazioni,
'obiettivi': obiettivi,
'documenti': documenti,
})

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'olimpic_nastri.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Gunicorn daemon per Olimpic Progetto Nastri (Django)
After=network.target
[Service]
User=marco
Group=www-data
WorkingDirectory=/home/marco/olimpic_nastri
Environment="DJANGO_SETTINGS_MODULE=olimpic_nastri.settings"
Environment="OLIMPIC_DB_PASSWORD=OlimpicNastri2026!"
ExecStart=/home/marco/olimpic_nastri/nastrivenv/bin/gunicorn \
--access-logfile - \
--workers 3 \
--bind unix:/run/olimpic_nastri/gunicorn.sock \
olimpic_nastri.wsgi:application
RuntimeDirectory=olimpic_nastri
Restart=on-failure
TimeoutStartSec=15
[Install]
WantedBy=multi-user.target

View File

16
olimpic_nastri/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for olimpic_nastri project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'olimpic_nastri.settings')
application = get_asgi_application()

143
olimpic_nastri/settings.py Normal file
View File

@@ -0,0 +1,143 @@
"""
Django settings for olimpic_nastri project.
Generated by 'django-admin startproject' using Django 5.2.12.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-ip*)m*6n9(-w)6+1x7iezyk(*gvucz*jtwng&=ozi3%uhs=gwa'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['diario.olimpic.click', 'localhost', '127.0.0.1']
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_htmx',
'diario',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
]
ROOT_URLCONF = 'olimpic_nastri.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'olimpic_nastri.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'olimpic_nastri_db',
'USER': 'olimpic_nastri',
'PASSWORD': os.environ.get('OLIMPIC_DB_PASSWORD', 'OlimpicNastri2026!'),
'HOST': 'localhost',
'PORT': '5432',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'it-it'
TIME_ZONE = 'Europe/Rome'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/login/'
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Max upload size (10 MB)
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024

32
olimpic_nastri/urls.py Normal file
View File

@@ -0,0 +1,32 @@
"""
URL configuration for olimpic_nastri project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.contrib.auth import views as auth_views
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('login/', auth_views.LoginView.as_view(template_name='diario/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('', include('diario.urls')),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

16
olimpic_nastri/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for olimpic_nastri project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'olimpic_nastri.settings')
application = get_wsgi_application()

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
asgiref==3.11.1
Django==5.2.12
django-htmx==1.27.0
gunicorn==25.3.0
packaging==26.0
psycopg2-binary==2.9.11
sqlparse==0.5.5
typing_extensions==4.15.0

329
static/css/style.css Normal file
View File

@@ -0,0 +1,329 @@
/* ── Olimpic Nastri — Style v2 ─────────────────────────────────────────── */
:root {
--bs-body-bg: #f0f2f5;
--accent: #4361ee;
--accent-soft: #eef0fd;
--accent-hover: #3451d1;
--danger: #ef4444;
--success-bg: #dcfce7;
--success-fg: #15803d;
}
body {
background: var(--bs-body-bg);
font-size: .93rem;
}
/* ── Navbar ── */
.navbar {
background: #1a1f36 !important;
box-shadow: 0 2px 8px rgba(0,0,0,.25);
padding-top: .6rem;
padding-bottom: .6rem;
}
.navbar-brand { font-weight: 800; font-size: 1.05rem; letter-spacing: .5px; color: #fff !important; }
.navbar-brand span { color: #7c8ff9; }
.nav-link { color: rgba(255,255,255,.75) !important; font-weight: 500; transition: color .15s; }
.nav-link:hover, .nav-link.active { color: #fff !important; }
/* ── Cards ── */
.card {
border: none;
border-radius: 12px;
box-shadow: 0 1px 6px rgba(0,0,0,.07);
background: #fff;
transition: box-shadow .2s ease, transform .2s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,.1);
}
.card-header-accent {
background: var(--accent-soft);
border-bottom: 1px solid #d9ddfc;
border-radius: 12px 12px 0 0 !important;
padding: .75rem 1.25rem;
font-weight: 600;
font-size: .85rem;
color: var(--accent);
letter-spacing: .3px;
}
/* ── Animations ── */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn .3s ease both;
}
.fade-in-delay-1 { animation-delay: .05s; }
.fade-in-delay-2 { animation-delay: .1s; }
.fade-in-delay-3 { animation-delay: .15s; }
/* HTMX transitions */
.htmx-settling {
opacity: 0;
}
.htmx-settling .container-lg,
.htmx-settling .fade-in {
animation: none;
}
#main-content {
transition: opacity .15s ease;
}
#main-content.htmx-swapping {
opacity: 0;
}
/* ── Timeline ── */
.timeline { position: relative; padding-left: 28px; }
.timeline::before {
content: '';
position: absolute; left: 10px; top: 4px; bottom: 4px;
width: 2px; background: #e0e4f0; border-radius: 2px;
}
.tl-item { position: relative; margin-bottom: 1.4rem; }
.tl-dot {
position: absolute; left: -24px; top: 3px;
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px currentColor;
}
.tl-dot.conv { color: var(--accent); background: var(--accent); }
.tl-dot.agg { color: #f97316; background: #f97316; }
.tl-dot.doc { color: #10b981; background: #10b981; }
/* ── Badge stati ── */
.badge-stato {
font-size: .7rem; font-weight: 600;
padding: .3em .65em; border-radius: 6px;
}
.stato-aperto { background: #dbeafe; color: #1d4ed8; }
.stato-in_corso { background: #ffedd5; color: #c2410c; }
.stato-completato{ background: #dcfce7; color: #15803d; }
.stato-sospeso { background: #f1f5f9; color: #64748b; }
/* ── Progress slider AJAX ── */
.slider-wrap { position: relative; }
.progress-slider {
-webkit-appearance: none;
width: 100%; height: 6px;
border-radius: 4px; outline: none; cursor: pointer;
background: linear-gradient(
to right,
var(--accent) 0%,
var(--accent) var(--val, 0%),
#dee2e6 var(--val, 0%),
#dee2e6 100%
);
transition: opacity .15s;
}
.progress-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px; height: 18px;
border-radius: 50%; cursor: pointer;
background: var(--accent);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(67,97,238,.5);
transition: transform .15s;
}
.progress-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.slider-label {
font-size: .78rem; font-weight: 700; color: var(--accent);
min-width: 36px; text-align: right;
}
.slider-saving { font-size: .72rem; color: #94a3b8; }
/* ── Pill tipo ── */
.pill-collettivo { background:#ede9fe; color:#6d28d9; }
.pill-individuale{ background:#fce7f3; color:#9d174d; }
.pill-tipo { font-size:.7rem; font-weight:600; padding:.25em .6em; border-radius:20px; }
/* ── Misc ── */
.section-title {
font-weight: 700; font-size: .8rem; text-transform: uppercase;
letter-spacing: .8px; color: #94a3b8; margin-bottom: .75rem;
}
.btn-icon {
width: 32px; height: 32px; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px;
}
.avatar {
width: 28px; height: 28px; border-radius: 50%;
background: var(--accent); color: #fff;
font-size: .7rem; font-weight: 700;
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.avatar-lg {
width: 48px; height: 48px; font-size: 1rem;
}
hr.soft { border-color: #f1f3f9; }
/* ── Forms ── */
textarea.form-control, input.form-control { border-radius: 10px; border-color: #e2e8f0; }
textarea.form-control:focus, input.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(67,97,238,.12); }
.form-select { border-radius: 10px; border-color: #e2e8f0; }
.form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(67,97,238,.12); }
/* ── Buttons ── */
.btn-primary { background: var(--accent); border-color: var(--accent); border-radius: 9px; font-weight: 600; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-outline-secondary { border-radius: 9px; }
.btn-outline-danger { border-radius: 9px; }
/* ── Search bar ── */
.search-wrapper {
position: relative;
}
.search-wrapper .form-control {
padding-left: 2.2rem;
border-radius: 20px;
background: rgba(255,255,255,.1);
border-color: rgba(255,255,255,.15);
color: #fff;
font-size: .85rem;
transition: background .2s, border-color .2s, width .3s;
width: 200px;
}
.search-wrapper .form-control::placeholder { color: rgba(255,255,255,.5); }
.search-wrapper .form-control:focus {
background: rgba(255,255,255,.18);
border-color: rgba(255,255,255,.3);
color: #fff;
width: 280px;
box-shadow: none;
}
.search-wrapper .search-icon {
position: absolute; left: .75rem; top: 50%; transform: translateY(-50%);
color: rgba(255,255,255,.5); font-size: .8rem; pointer-events: none;
}
.search-results-dropdown {
position: absolute; top: 100%; left: 0; right: 0;
margin-top: 4px;
background: #fff; border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.15);
z-index: 1050; max-height: 400px; overflow-y: auto;
display: none;
}
.search-results-dropdown.show { display: block; }
.search-results-dropdown .result-item {
padding: .6rem 1rem; cursor: pointer;
transition: background .1s;
color: #1a1f36; text-decoration: none; display: block;
}
.search-results-dropdown .result-item:hover { background: var(--accent-soft); }
.search-results-dropdown .result-type {
font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
color: var(--accent); margin-bottom: .15rem;
}
/* ── Toast container ── */
.toast-container {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 1090;
}
.toast {
border-radius: 10px; border: none;
box-shadow: 0 4px 16px rgba(0,0,0,.12);
animation: fadeIn .25s ease;
}
/* ── Person card ── */
.person-card {
text-align: center;
padding: 1.5rem 1rem;
}
.person-card .avatar { margin-bottom: .75rem; }
.person-card .stat { font-size: .75rem; color: #94a3b8; }
.person-card .stat-num { font-weight: 700; color: var(--accent); font-size: .9rem; }
/* ── Document badge ── */
.doc-badge {
background: #ecfdf5; color: #059669;
font-size: .7rem; font-weight: 600;
padding: .25em .6em; border-radius: 6px;
}
/* ── Clickable card ── */
.card-link {
text-decoration: none;
color: inherit;
display: block;
}
.card-link:hover .card {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,.1);
}
/* ── Empty state ── */
.empty-state {
text-align: center; padding: 3rem 1rem; color: #94a3b8;
}
.empty-state i {
font-size: 2.5rem; opacity: .3; margin-bottom: .75rem; display: block;
}
/* ── Countdown badges ── */
.countdown {
font-size: .7rem; font-weight: 600;
padding: .2em .55em; border-radius: 6px;
display: inline-block;
}
.countdown-urgent {
background: #fef2f2; color: #dc2626;
}
.countdown-soon {
background: #fff7ed; color: #c2410c;
}
.countdown-ok {
background: #ecfdf5; color: #059669;
}
/* ── Agenda ── */
.agenda-row {
transition: background .15s ease;
}
.agenda-row:hover {
background: #f8fafc;
}
.agenda-row-scaduto {
background: #fef2f2;
}
.agenda-row-scaduto:hover {
background: #fee2e2;
}
.agenda-date {
width: 42px;
}
.agenda-day {
font-size: 1.1rem; font-weight: 700; line-height: 1.1; color: #1a1f36;
}
.agenda-month {
font-size: .6rem; text-transform: uppercase; letter-spacing: .5px; color: #94a3b8;
}
.agenda-icon {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: .85rem; flex-shrink: 0;
}
.agenda-icon-scadenza {
background: var(--accent-soft); color: var(--accent);
}
.agenda-icon-conv {
background: #f0f9ff; color: #0284c7;
}
.agenda-icon-danger {
background: #fef2f2; color: #dc2626;
}
/* ── Comment actions dropdown ── */
.card .dropdown .btn-icon {
opacity: 0; transition: opacity .15s;
}
.card:hover .dropdown .btn-icon {
opacity: 1;
}

157
static/js/app.js Normal file
View File

@@ -0,0 +1,157 @@
/* ── Olimpic Nastri — App JS v2 ─────────────────────────────────────────── */
// ── CSRF helper ──
function getCsrf() {
return document.cookie.split('; ')
.find(r => r.startsWith('csrftoken='))
?.split('=')[1] ?? '';
}
// ── Toast notifications ──
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
if (!container) return;
const icons = {
success: 'bi-check-circle-fill',
danger: 'bi-exclamation-triangle-fill',
info: 'bi-info-circle-fill',
};
const colors = {
success: '#15803d',
danger: '#dc2626',
info: '#4361ee',
};
const id = 'toast-' + Date.now();
const html = `
<div id="${id}" class="toast align-items-center text-white border-0 mb-2" role="alert"
style="background:${colors[type] || colors.success};">
<div class="d-flex">
<div class="toast-body d-flex align-items-center gap-2">
<i class="bi ${icons[type] || icons.success}"></i>
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`;
container.insertAdjacentHTML('beforeend', html);
const toastEl = document.getElementById(id);
const bsToast = new bootstrap.Toast(toastEl, { delay: 3000 });
bsToast.show();
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
}
// ── Convert Django messages to toasts on page load ──
function convertMessagesToToasts() {
document.querySelectorAll('[data-toast-message]').forEach(el => {
showToast(el.dataset.toastMessage, el.dataset.toastType || 'success');
el.remove();
});
}
// ── Progress slider AJAX ──
function initSliders() {
document.querySelectorAll('.progress-slider').forEach(slider => {
if (slider._sliderInit) return;
slider._sliderInit = true;
const id = slider.dataset.id;
const lbl = document.getElementById('lbl-' + id);
const saving = document.getElementById('saving-' + id);
let timer = null;
slider.addEventListener('input', () => {
const v = slider.value;
if (lbl) lbl.textContent = v + '%';
slider.style.setProperty('--val', v + '%');
});
slider.addEventListener('change', () => {
clearTimeout(timer);
if (saving) {
saving.classList.remove('d-none');
saving.textContent = 'salvataggio…';
}
timer = setTimeout(() => {
fetch(slider.dataset.url, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'avanzamento=' + slider.value,
})
.then(r => r.json())
.then(d => {
if (saving) {
saving.textContent = d.ok ? '✓ salvato' : '⚠ errore';
setTimeout(() => saving.classList.add('d-none'), 1500);
}
})
.catch(() => {
if (saving) {
saving.textContent = '⚠ errore';
setTimeout(() => saving.classList.add('d-none'), 1500);
}
});
}, 400);
});
});
}
// ── Live search dropdown ──
function initLiveSearch() {
const input = document.getElementById('search-input');
const dropdown = document.getElementById('search-dropdown');
if (!input || !dropdown) return;
let timer = null;
input.addEventListener('input', () => {
const q = input.value.trim();
clearTimeout(timer);
if (q.length < 2) {
dropdown.classList.remove('show');
dropdown.innerHTML = '';
return;
}
timer = setTimeout(() => {
fetch('/ricerca/?q=' + encodeURIComponent(q), {
headers: { 'HX-Request': 'true' }
})
.then(r => r.text())
.then(html => {
dropdown.innerHTML = html;
dropdown.classList.add('show');
});
}, 300);
});
// Close dropdown on click outside
document.addEventListener('click', (e) => {
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.classList.remove('show');
}
});
// Navigate to full search on Enter
input.closest('form')?.addEventListener('submit', (e) => {
dropdown.classList.remove('show');
});
}
// ── Init everything ──
function initApp() {
convertMessagesToToasts();
initSliders();
initLiveSearch();
}
// Run on initial page load
document.addEventListener('DOMContentLoaded', initApp);
// Re-init after HTMX swaps
document.addEventListener('htmx:afterSettle', initApp);

View File

@@ -0,0 +1,164 @@
{% extends "diario/base.html" %}
{% load custom_filters %}
{% block title %}Agenda Olimpic Nastri{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-9">
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
<div>
<p class="section-title mb-0">Agenda</p>
<small class="text-muted">Panoramica di scadenze e appuntamenti</small>
</div>
<div class="d-flex gap-2">
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus-lg me-1"></i>Obiettivo
</a>
<a href="{% url 'conversazione_nuova' %}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-lg me-1"></i>Conversazione
</a>
</div>
</div>
<!-- ── Prossimi eventi ── -->
<div class="card mb-4 fade-in">
<div class="card-header-accent d-flex align-items-center">
<i class="bi bi-arrow-right-circle me-2"></i>
<span>Prossimi eventi</span>
<span class="badge bg-primary bg-opacity-10 text-primary ms-2">{{ eventi_futuri|length }}</span>
</div>
<div class="p-0">
{% for ev in eventi_futuri %}
<div class="d-flex align-items-center gap-3 px-4 py-3 {% if not forloop.last %}border-bottom{% endif %} agenda-row">
<!-- Data -->
<div class="agenda-date text-center flex-shrink-0">
<div class="agenda-day">{{ ev.data|date:"d" }}</div>
<div class="agenda-month">{{ ev.data|date:"M" }}</div>
</div>
<!-- Icona tipo -->
{% if ev.tipo == 'scadenza' %}
<div class="agenda-icon agenda-icon-scadenza">
<i class="bi bi-bullseye"></i>
</div>
{% else %}
<div class="agenda-icon agenda-icon-conv">
<i class="bi bi-chat-quote"></i>
</div>
{% endif %}
<!-- Dettaglio -->
<div class="flex-grow-1">
{% if ev.tipo == 'scadenza' %}
<a href="{% url 'obiettivo_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
{{ ev.obj.titolo }}
</a>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="badge-stato stato-{{ ev.obj.stato }}">{{ ev.obj.get_stato_display }}</span>
<span class="pill-tipo pill-{{ ev.obj.tipo }}">{{ ev.obj.get_tipo_display }}</span>
{% with days=ev.obj.giorni_rimanenti %}
{% if days == 0 %}
<span class="countdown countdown-urgent">Oggi!</span>
{% elif days == 1 %}
<span class="countdown countdown-urgent">Domani</span>
{% elif days <= 7 %}
<span class="countdown countdown-soon">{{ days }} giorni</span>
{% else %}
<span class="countdown countdown-ok">{{ days }} giorni</span>
{% endif %}
{% endwith %}
</div>
{% else %}
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-dark fw-semibold d-block">
{{ ev.obj.titolo }}
</a>
<div class="d-flex align-items-center gap-2 mt-1">
<small class="text-muted">
<i class="bi bi-clock me-1"></i>{{ ev.obj.data|date:"H:i" }}
</small>
<small class="text-muted">
{{ ev.obj.registrato_da.get_full_name|default:ev.obj.registrato_da.username }}
</small>
</div>
{% endif %}
</div>
<!-- Progress (se scadenza) -->
{% if ev.tipo == 'scadenza' %}
<div class="d-none d-md-block" style="width:80px;">
<div class="progress" style="height:6px; border-radius:3px;">
<div class="progress-bar" role="progressbar"
style="width:{{ ev.obj.avanzamento }}%; background:var(--accent);">
</div>
</div>
<small class="text-muted d-block text-center mt-1" style="font-size:.7rem;">{{ ev.obj.avanzamento }}%</small>
</div>
{% endif %}
</div>
{% empty %}
<div class="empty-state py-4">
<i class="bi bi-calendar-check"></i>
<p>Nessun evento programmato. Tutto in ordine!</p>
</div>
{% endfor %}
</div>
</div>
<!-- ── Eventi passati ── -->
<div class="card fade-in">
<div class="card-header-accent d-flex align-items-center" style="background:#f8f9fa; color:#64748b; border-bottom-color:#e2e8f0;">
<i class="bi bi-clock-history me-2"></i>
<span>Ultimi 30 giorni</span>
<span class="badge bg-secondary bg-opacity-10 text-secondary ms-2">{{ eventi_passati|length }}</span>
</div>
<div class="p-0">
{% for ev in eventi_passati %}
<div class="d-flex align-items-center gap-3 px-4 py-3 {% if not forloop.last %}border-bottom{% endif %} agenda-row {% if ev.scaduto %}agenda-row-scaduto{% endif %}">
<!-- Data -->
<div class="agenda-date text-center flex-shrink-0 {% if ev.scaduto %}text-danger{% endif %}">
<div class="agenda-day">{{ ev.data|date:"d" }}</div>
<div class="agenda-month">{{ ev.data|date:"M" }}</div>
</div>
<!-- Icona -->
{% if ev.tipo == 'scadenza' %}
<div class="agenda-icon {% if ev.scaduto %}agenda-icon-danger{% else %}agenda-icon-scadenza{% endif %}">
<i class="bi {% if ev.scaduto %}bi-exclamation-triangle{% else %}bi-bullseye{% endif %}"></i>
</div>
{% else %}
<div class="agenda-icon agenda-icon-conv" style="opacity:.6;">
<i class="bi bi-chat-quote"></i>
</div>
{% endif %}
<!-- Dettaglio -->
<div class="flex-grow-1">
{% if ev.tipo == 'scadenza' %}
<a href="{% url 'obiettivo_dettaglio' ev.obj.pk %}" class="text-decoration-none {% if ev.scaduto %}text-danger{% else %}text-dark{% endif %} fw-semibold d-block">
{{ ev.obj.titolo }}
{% if ev.scaduto %}<small class="text-danger fw-normal">(scaduto)</small>{% endif %}
</a>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="badge-stato stato-{{ ev.obj.stato }}">{{ ev.obj.get_stato_display }}</span>
</div>
{% else %}
<a href="{% url 'conversazione_dettaglio' ev.obj.pk %}" class="text-decoration-none text-muted fw-semibold d-block">
{{ ev.obj.titolo }}
</a>
<small class="text-muted">{{ ev.obj.registrato_da.get_full_name|default:ev.obj.registrato_da.username }}</small>
{% endif %}
</div>
</div>
{% empty %}
<div class="empty-state py-4">
<i class="bi bi-clock-history"></i>
<p>Nessun evento negli ultimi 30 giorni.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

120
templates/diario/base.html Normal file
View File

@@ -0,0 +1,120 @@
{% load static %}
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Olimpic Nastri{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="{% static 'css/style.css' %}" rel="stylesheet">
</head>
<body hx-boost="true">
<nav class="navbar navbar-expand-lg mb-4">
<div class="container-lg">
<a class="navbar-brand" href="{% url 'dashboard' %}">
<i class="bi bi-journal-richtext me-2" style="color:#7c8ff9;"></i>Olimpic <span>Nastri</span>
</a>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navMenu">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMenu">
<ul class="navbar-nav me-auto gap-1">
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'dashboard' %}">
<i class="bi bi-house-door me-1"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'conversazioni_lista' %}">
<i class="bi bi-chat-quote me-1"></i>Conversazioni
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'obiettivi_lista' %}">
<i class="bi bi-bullseye me-1"></i>Obiettivi
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'agenda' %}">
<i class="bi bi-calendar-week me-1"></i>Agenda
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'documenti_lista' %}">
<i class="bi bi-file-earmark-pdf me-1"></i>Documenti
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-1 rounded-pill" href="{% url 'persone_lista' %}">
<i class="bi bi-people me-1"></i>Persone
</a>
</li>
</ul>
{% if user.is_authenticated %}
<ul class="navbar-nav align-items-center gap-2">
<!-- Search bar -->
<li class="nav-item">
<div class="search-wrapper">
<i class="bi bi-search search-icon"></i>
<form action="{% url 'ricerca' %}" method="get" hx-boost="true">
<input type="search" name="q" id="search-input"
class="form-control form-control-sm"
placeholder="Cerca..." autocomplete="off">
</form>
<div id="search-dropdown" class="search-results-dropdown"></div>
</div>
</li>
<li class="nav-item">
<a href="{% url 'conversazione_nuova' %}" class="btn btn-sm btn-primary px-3">
<i class="bi bi-plus-lg me-1"></i>Registra
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center gap-2 px-2" href="#" data-bs-toggle="dropdown" data-bs-auto-close="true">
<span class="avatar">{{ user.username|slice:":2"|upper }}</span>
<span class="d-none d-sm-inline" style="color:rgba(255,255,255,.85);">{{ user.get_full_name|default:user.username }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end shadow border-0" style="border-radius:12px; min-width:180px;">
{% if user.is_staff %}
<li><a class="dropdown-item rounded-3" href="/admin/" hx-boost="false"><i class="bi bi-gear me-2 text-muted"></i>Pannello Admin</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li>
<form method="post" action="{% url 'logout' %}" hx-boost="false">
{% csrf_token %}
<button type="submit" class="dropdown-item rounded-3 text-danger">
<i class="bi bi-box-arrow-right me-2"></i>Esci
</button>
</form>
</li>
</ul>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>
<div class="container-lg" id="main-content">
{% if messages %}
{% for message in messages %}
<div data-toast-message="{{ message }}" data-toast-type="{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %}" class="d-none"></div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
<!-- Toast container -->
<div id="toast-container" class="toast-container"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="{% static 'js/app.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,33 @@
{% extends "diario/base.html" %}
{% block title %}Modifica {{ tipo }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6 fade-in">
<div class="d-flex align-items-center mb-4 gap-3">
<a href="{{ back_url }}" class="btn btn-icon btn-outline-secondary">
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
</a>
<h5 class="mb-0 fw-bold">Modifica {{ tipo }}</h5>
</div>
<div class="card p-4">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold small">Testo</label>
{{ form.testo }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-lg me-1"></i>Salva
</button>
<a href="{{ back_url }}" class="btn btn-outline-secondary px-4">Annulla</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "diario/base.html" %}
{% block title %}Conferma eliminazione{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6 fade-in">
<div class="card p-4 text-center">
<div class="mb-3">
<i class="bi bi-exclamation-triangle" style="font-size:2.5rem; color:#ef4444;"></i>
</div>
<h5 class="fw-bold mb-2">Elimina {{ tipo }}</h5>
<p class="text-muted mb-4">
Stai per eliminare <strong>{{ oggetto }}</strong>.<br>
Questa azione è irreversibile.
</p>
<div class="d-flex justify-content-center gap-2">
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger px-4">
<i class="bi bi-trash me-1"></i>Elimina
</button>
</form>
<a href="{% if cancel_pk %}{% url cancel_url cancel_pk %}{% else %}{% url cancel_url oggetto.pk %}{% endif %}" class="btn btn-outline-secondary px-4">Annulla</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends "diario/base.html" %}
{% block title %}{{ conv.titolo }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex align-items-center mb-4 gap-3 fade-in">
<a href="{% url 'conversazioni_lista' %}" class="btn btn-icon btn-outline-secondary">
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
</a>
<h5 class="mb-0 fw-bold flex-grow-1">{{ conv.titolo }}</h5>
{% if can_edit %}
<a href="{% url 'conversazione_modifica' conv.pk %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Modifica
</a>
<a href="{% url 'conversazione_elimina' conv.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Elimina
</a>
{% endif %}
</div>
<div class="card p-4 mb-4 fade-in">
<div class="d-flex align-items-center gap-3 mb-4">
<span class="avatar">{{ conv.registrato_da.username|slice:":2"|upper }}</span>
<div>
<div class="fw-semibold small">{{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }}</div>
<small class="text-muted">{{ conv.data|date:"d/m/Y \a\l\l\e H:i" }}</small>
</div>
</div>
<div style="white-space:pre-wrap;line-height:1.8;font-size:.93rem;">{{ conv.contenuto }}</div>
{% if conv.partecipanti.all %}
<hr class="soft mt-4 mb-3">
<div>
<small class="text-muted fw-semibold d-block mb-2">Partecipanti</small>
<div class="d-flex flex-wrap gap-2">
{% for p in conv.partecipanti.all %}
<div class="d-flex align-items-center gap-1 px-2 py-1 rounded-pill" style="background:#f0f2f5; font-size:.8rem;">
<span class="avatar" style="width:20px;height:20px;font-size:.55rem;">{{ p.username|slice:":2"|upper }}</span>
{{ p.get_full_name|default:p.username }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Documenti allegati -->
<div class="d-flex justify-content-between align-items-center mb-3 fade-in">
<p class="section-title mb-0"><i class="bi bi-paperclip me-1"></i>Allegati ({{ documenti|length }})</p>
<a href="{% url 'documento_nuovo' %}?conversazione={{ conv.pk }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-upload me-1"></i>Allega PDF
</a>
</div>
{% for doc in documenti %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-file-earmark-pdf" style="font-size:1.5rem;color:#dc3545;"></i>
<div class="flex-grow-1">
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
{{ doc.titolo }}
</a>
<small class="text-muted">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} · {{ doc.data_caricamento|date:"d/m/Y" }}</small>
</div>
<a href="{{ doc.file.url }}" class="btn btn-sm btn-outline-secondary" hx-boost="false" target="_blank">
<i class="bi bi-download"></i>
</a>
</div>
</div>
{% empty %}
<p class="text-muted small text-center py-2 fade-in">Nessun documento allegato.</p>
{% endfor %}
<!-- Commenti -->
<p class="section-title mt-4 fade-in"><i class="bi bi-chat-dots me-1"></i>Commenti ({{ commenti|length }})</p>
<!-- Form nuovo commento -->
<div class="card p-3 mb-4 fade-in" style="border-left: 3px solid var(--accent) !important;">
<form method="post">
{% csrf_token %}
<label class="form-label fw-semibold small mb-2">Aggiungi un commento</label>
{{ comment_form.testo }}
<button type="submit" class="btn btn-primary btn-sm mt-2 px-3">
<i class="bi bi-send me-1"></i>Pubblica
</button>
</form>
</div>
{% for c in commenti %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex justify-content-between align-items-start">
<div class="d-flex align-items-center gap-2">
<span class="avatar" style="width:28px;height:28px;font-size:.65rem;">{{ c.autore.username|slice:":2"|upper }}</span>
<span class="fw-semibold small">{{ c.autore.get_full_name|default:c.autore.username }}</span>
</div>
<div class="d-flex align-items-center gap-2">
<small class="text-muted">{{ c.data|date:"d/m/Y H:i" }}</small>
{% if user == c.autore or user.is_superuser %}
<div class="dropdown">
<button class="btn btn-icon btn-outline-secondary" style="width:24px;height:24px;" data-bs-toggle="dropdown">
<i class="bi bi-three-dots" style="font-size:.7rem;"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0" style="border-radius:10px; min-width:140px;">
<li><a class="dropdown-item small" href="{% url 'commento_modifica' c.pk %}"><i class="bi bi-pencil me-2 text-muted"></i>Modifica</a></li>
<li><a class="dropdown-item small text-danger" href="{% url 'commento_elimina' c.pk %}"><i class="bi bi-trash me-2"></i>Elimina</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
<p class="mb-0 mt-2 small" style="white-space:pre-wrap;line-height:1.65;padding-left:40px;">{{ c.testo }}</p>
</div>
{% empty %}
<p class="text-muted small text-center py-3">Nessun commento ancora. Sii il primo!</p>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "diario/base.html" %}
{% block title %}{{ titolo_pagina }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex align-items-center mb-4">
<a href="{% url 'conversazioni_lista' %}" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0">{{ titolo_pagina }}</h4>
</div>
<div class="card p-4">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.titolo.label }}</label>
{{ form.titolo }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.data.label }}</label>
{{ form.data }}
<div class="form-text">Puoi inserire una data passata per conversazioni già avvenute.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.contenuto.label }}</label>
{{ form.contenuto }}
</div>
<div class="mb-4">
<label class="form-label fw-semibold">{{ form.partecipanti.label }}</label>
<div class="row row-cols-2 row-cols-sm-3 g-2 mt-1">
{% for checkbox in form.partecipanti %}
<div class="col">
<div class="form-check">
{{ checkbox.tag }}
<label class="form-check-label" for="{{ checkbox.id_for_label }}">
{{ checkbox.choice_label }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Salva</button>
{% if conv %}
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="btn btn-outline-secondary">Annulla</a>
{% else %}
<a href="{% url 'conversazioni_lista' %}" class="btn btn-outline-secondary">Annulla</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "diario/base.html" %}
{% block title %}Conversazioni{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
<p class="section-title mb-0">Cronologia conversazioni</p>
<a href="{% url 'conversazione_nuova' %}" class="btn btn-primary btn-sm px-3">
<i class="bi bi-plus-lg me-1"></i>Nuova conversazione
</a>
</div>
{% for conv in conversazioni %}
<div class="card mb-3 p-3 fade-in">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="flex-grow-1">
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="text-decoration-none text-dark fw-semibold d-block mb-1">
{{ conv.titolo }}
</a>
<div class="d-flex align-items-center gap-3 flex-wrap">
<small class="text-muted"><i class="bi bi-calendar3 me-1"></i>{{ conv.data|date:"d/m/Y H:i" }}</small>
<div class="d-flex align-items-center gap-1">
<span class="avatar" style="width:20px;height:20px;font-size:.58rem;">{{ conv.registrato_da.username|slice:":2"|upper }}</span>
<small class="text-muted">{{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }}</small>
</div>
{% if conv.partecipanti.count > 0 %}
<small class="text-muted"><i class="bi bi-people me-1"></i>{{ conv.partecipanti.count }}</small>
{% endif %}
</div>
<p class="mb-0 text-muted small mt-2">{{ conv.contenuto|truncatewords:30 }}</p>
</div>
{% if user == conv.registrato_da or user.is_superuser %}
<a href="{% url 'conversazione_modifica' conv.pk %}" class="btn btn-icon btn-outline-secondary flex-shrink-0">
<i class="bi bi-pencil" style="font-size:.75rem;"></i>
</a>
{% endif %}
</div>
</div>
{% empty %}
<div class="empty-state">
<i class="bi bi-chat-square-dots"></i>
<p>Nessuna conversazione registrata.</p>
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,237 @@
{% extends "diario/base.html" %}
{% load custom_filters %}
{% block title %}Dashboard Olimpic Nastri{% endblock %}
{% block content %}
<div class="row g-4">
<!-- ── Colonna principale: Timeline ── -->
<div class="col-lg-7">
<div class="d-flex justify-content-between align-items-center mb-3 fade-in">
<p class="section-title mb-0">Cronologia attività</p>
<a href="{% url 'conversazione_nuova' %}" class="btn btn-primary btn-sm px-3">
<i class="bi bi-plus-lg me-1"></i>Nuova conversazione
</a>
</div>
<div class="timeline">
{% for evento in eventi %}
<div class="tl-item fade-in">
{% if evento.tipo == 'conversazione' %}
<span class="tl-dot conv"></span>
<div class="card p-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge bg-primary bg-opacity-10 text-primary pill-tipo mb-1">
<i class="bi bi-chat-quote me-1"></i>Conversazione
</span>
<div class="fw-semibold">
<a href="{% url 'conversazione_dettaglio' evento.obj.pk %}" class="text-decoration-none text-dark">
{{ evento.obj.titolo }}
</a>
</div>
</div>
<small class="text-muted text-nowrap ms-2">{{ evento.data|date:"d/m H:i" }}</small>
</div>
<p class="text-muted small mb-1 mt-2">{{ evento.obj.contenuto|truncatewords:25 }}</p>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="avatar" style="width:22px;height:22px;font-size:.62rem;">
{{ evento.obj.registrato_da.username|slice:":2"|upper }}
</span>
<small class="text-muted">{{ evento.obj.registrato_da.get_full_name|default:evento.obj.registrato_da.username }}</small>
{% if evento.obj.partecipanti.count > 0 %}
<small class="text-muted">· {{ evento.obj.partecipanti.count }} partecipanti</small>
{% endif %}
</div>
</div>
{% elif evento.tipo == 'documento' %}
<span class="tl-dot doc"></span>
<div class="card p-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge mb-1 pill-tipo" style="background:#ecfdf5;color:#059669;">
<i class="bi bi-file-earmark-pdf me-1"></i>Documento
</span>
<div class="fw-semibold">
<a href="{% url 'documento_dettaglio' evento.obj.pk %}" class="text-decoration-none text-dark">
{{ evento.obj.titolo }}
</a>
</div>
</div>
<small class="text-muted text-nowrap ms-2">{{ evento.data|date:"d/m H:i" }}</small>
</div>
{% if evento.obj.descrizione %}
<p class="text-muted small mb-1 mt-2">{{ evento.obj.descrizione|truncatewords:20 }}</p>
{% endif %}
<div class="d-flex align-items-center gap-2 mt-1">
<span class="avatar" style="width:22px;height:22px;font-size:.62rem;">
{{ evento.obj.caricato_da.username|slice:":2"|upper }}
</span>
<small class="text-muted">{{ evento.obj.caricato_da.get_full_name|default:evento.obj.caricato_da.username }}</small>
</div>
</div>
{% else %}
<span class="tl-dot agg"></span>
<div class="card p-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge mb-1 pill-tipo" style="background:#ffedd5;color:#c2410c;">
<i class="bi bi-arrow-up-circle me-1"></i>Aggiornamento obiettivo
</span>
<div class="fw-semibold">
<a href="{% url 'obiettivo_dettaglio' evento.obj.obiettivo.pk %}" class="text-decoration-none text-dark">
{{ evento.obj.obiettivo.titolo }}
</a>
</div>
</div>
<small class="text-muted text-nowrap ms-2">{{ evento.data|date:"d/m H:i" }}</small>
</div>
<p class="text-muted small mb-1 mt-2">{{ evento.obj.testo|truncatewords:25 }}</p>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="avatar" style="width:22px;height:22px;font-size:.62rem;">
{{ evento.obj.autore.username|slice:":2"|upper }}
</span>
<small class="text-muted">{{ evento.obj.autore.get_full_name|default:evento.obj.autore.username }}</small>
</div>
</div>
{% endif %}
</div>
{% empty %}
<div class="empty-state">
<i class="bi bi-hourglass"></i>
<p>Nessuna attività ancora. Inizia registrando una conversazione.</p>
</div>
{% endfor %}
</div>
</div>
<!-- ── Sidebar destra ── -->
<div class="col-lg-5">
<!-- Obiettivi aperti con slider AJAX -->
<div class="card mb-4 fade-in">
<div class="card-header-accent d-flex justify-content-between align-items-center">
<span><i class="bi bi-bullseye me-2"></i>Obiettivi in corso</span>
<a href="{% url 'obiettivi_lista' %}" class="small fw-normal" style="color:var(--accent);">Vedi tutti →</a>
</div>
<div class="p-3">
{% for obj in obiettivi_aperti %}
<div class="mb-3 {% if not forloop.last %}pb-3 border-bottom{% endif %}">
<div class="d-flex justify-content-between align-items-center mb-1">
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold small">
{{ obj.titolo }}
</a>
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
</div>
{% if obj.data_scadenza %}
<div class="mb-1">
{% with days=obj.giorni_rimanenti %}
{% if days is not None %}
{% if days < 0 %}
<span class="countdown countdown-urgent" style="font-size:.65rem;"><i class="bi bi-exclamation-triangle-fill me-1"></i>Scaduto da {{ days|abs_val }} gg</span>
{% elif days == 0 %}
<span class="countdown countdown-urgent" style="font-size:.65rem;">Scade oggi!</span>
{% elif days <= 3 %}
<span class="countdown countdown-urgent" style="font-size:.65rem;">{{ days }} gg rimasti</span>
{% elif days <= 7 %}
<span class="countdown countdown-soon" style="font-size:.65rem;">{{ days }} gg rimasti</span>
{% else %}
<span class="countdown countdown-ok" style="font-size:.65rem;">{{ days }} gg · {{ obj.data_scadenza|date:"d/m" }}</span>
{% endif %}
{% endif %}
{% endwith %}
</div>
{% endif %}
<!-- Slider AJAX -->
<div class="d-flex align-items-center gap-2 mt-2">
<input
type="range" min="0" max="100" step="5"
value="{{ obj.avanzamento }}"
class="progress-slider flex-grow-1"
style="--val: {{ obj.avanzamento }}%;"
data-url="{% url 'obiettivo_avanzamento_ajax' obj.pk %}"
data-id="{{ obj.pk }}"
>
<span class="slider-label" id="lbl-{{ obj.pk }}">{{ obj.avanzamento }}%</span>
</div>
<span class="slider-saving d-none" id="saving-{{ obj.pk }}">salvataggio…</span>
</div>
{% empty %}
<p class="text-muted small text-center py-3 mb-0">Tutti gli obiettivi sono completati!</p>
{% endfor %}
<div class="mt-2">
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-sm btn-outline-secondary w-100">
<i class="bi bi-plus-lg me-1"></i>Aggiungi obiettivo
</a>
</div>
</div>
</div>
<!-- Agenda rapida -->
<div class="card mb-4 fade-in">
<div class="card-header-accent d-flex justify-content-between align-items-center" style="background:#fff7ed; color:#c2410c; border-bottom-color:#fed7aa;">
<span><i class="bi bi-calendar-week me-2"></i>Prossime scadenze</span>
<a href="{% url 'agenda' %}" class="small fw-normal" style="color:#c2410c;">Agenda →</a>
</div>
<div class="p-0">
{% for obj in scadenze_prossime %}
<div class="d-flex align-items-center gap-3 px-3 py-2 {% if not forloop.last %}border-bottom{% endif %}">
<div class="text-center flex-shrink-0" style="width:36px;">
<div style="font-size:.85rem; font-weight:700; color:var(--accent);">{{ obj.data_scadenza|date:"d" }}</div>
<div style="font-size:.6rem; text-transform:uppercase; color:#94a3b8;">{{ obj.data_scadenza|date:"M" }}</div>
</div>
<div class="flex-grow-1">
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
{{ obj.titolo|truncatewords:8 }}
</a>
{% with days=obj.giorni_rimanenti %}
{% if days <= 3 %}
<span class="countdown countdown-urgent" style="font-size:.6rem;">{{ days }} gg</span>
{% elif days <= 7 %}
<span class="countdown countdown-soon" style="font-size:.6rem;">{{ days }} gg</span>
{% else %}
<span class="countdown countdown-ok" style="font-size:.6rem;">{{ days }} gg</span>
{% endif %}
{% endwith %}
</div>
<div style="width:50px;">
<div class="progress" style="height:4px; border-radius:2px;">
<div class="progress-bar" style="width:{{ obj.avanzamento }}%; background:var(--accent);"></div>
</div>
</div>
</div>
{% empty %}
<p class="text-muted small text-center py-3 mb-0">Nessuna scadenza prossima</p>
{% endfor %}
</div>
</div>
{% if scaduti %}
<!-- Scaduti -->
<div class="card fade-in">
<div class="card-header-accent d-flex align-items-center" style="background:#fef2f2; color:#dc2626; border-bottom-color:#fecaca;">
<i class="bi bi-exclamation-triangle me-2"></i>
<span>Obiettivi scaduti</span>
<span class="badge bg-danger bg-opacity-10 text-danger ms-2">{{ scaduti|length }}</span>
</div>
<div class="p-0">
{% for obj in scaduti %}
<div class="d-flex align-items-center gap-3 px-3 py-2 {% if not forloop.last %}border-bottom{% endif %}">
<i class="bi bi-exclamation-circle text-danger"></i>
<div class="flex-grow-1">
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-danger fw-semibold small d-block">
{{ obj.titolo|truncatewords:8 }}
</a>
<small class="text-muted">Scaduto il {{ obj.data_scadenza|date:"d/m/Y" }}</small>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends "diario/base.html" %}
{% block title %}{{ doc.titolo }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8 fade-in">
<div class="d-flex align-items-center mb-4 gap-3">
<a href="{% url 'documenti_lista' %}" class="btn btn-icon btn-outline-secondary">
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
</a>
<h5 class="mb-0 fw-bold flex-grow-1">{{ doc.titolo }}</h5>
{% if can_edit %}
<a href="{% url 'documento_elimina' doc.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Elimina
</a>
{% endif %}
</div>
<div class="card p-4 mb-4">
<div class="d-flex align-items-center gap-3 mb-3">
<div class="d-flex align-items-center justify-content-center rounded-3" style="width:52px;height:52px;background:#fef2f2;">
<i class="bi bi-file-earmark-pdf" style="font-size:1.6rem;color:#ef4444;"></i>
</div>
<div>
<div class="fw-semibold">{{ doc.filename }}</div>
<small class="text-muted">Caricato il {{ doc.data_caricamento|date:"d/m/Y \a\l\l\e H:i" }}</small>
</div>
</div>
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-primary mb-3" hx-boost="false">
<i class="bi bi-download me-2"></i>Scarica PDF
</a>
{% if doc.descrizione %}
<hr class="soft my-3">
<p class="mb-0" style="white-space:pre-wrap;line-height:1.7;">{{ doc.descrizione }}</p>
{% endif %}
<hr class="soft my-3">
<div class="row g-3">
<div class="col-sm-4">
<small class="text-muted d-block mb-1">Caricato da</small>
<div class="d-flex align-items-center gap-2">
<span class="avatar" style="width:22px;height:22px;font-size:.58rem;">{{ doc.caricato_da.username|slice:":2"|upper }}</span>
<span class="small">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }}</span>
</div>
</div>
{% if doc.conversazione %}
<div class="col-sm-4">
<small class="text-muted d-block mb-1">Conversazione</small>
<a href="{% url 'conversazione_dettaglio' doc.conversazione.pk %}" class="small text-decoration-none" style="color:var(--accent);">
{{ doc.conversazione.titolo }}
</a>
</div>
{% endif %}
{% if doc.obiettivo %}
<div class="col-sm-4">
<small class="text-muted d-block mb-1">Obiettivo</small>
<a href="{% url 'obiettivo_dettaglio' doc.obiettivo.pk %}" class="small text-decoration-none" style="color:var(--accent);">
{{ doc.obiettivo.titolo }}
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "diario/base.html" %}
{% block title %}{{ titolo_pagina }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8 fade-in">
<div class="d-flex align-items-center mb-4">
<a href="{% url 'documenti_lista' %}" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0">{{ titolo_pagina }}</h4>
</div>
<div class="card p-4">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.titolo.label }}</label>
{{ form.titolo }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.descrizione.label }}</label>
{{ form.descrizione }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.file.label }}</label>
{{ form.file }}
<div class="form-text">Solo file PDF, massimo 10 MB.</div>
{% if form.file.errors %}
<div class="text-danger small mt-1">{{ form.file.errors.0 }}</div>
{% endif %}
</div>
<div class="row g-3 mb-4">
<div class="col-sm-6">
<label class="form-label fw-semibold">{{ form.conversazione.label }}</label>
{{ form.conversazione }}
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold">{{ form.obiettivo.label }}</label>
{{ form.obiettivo }}
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload me-1"></i>Carica
</button>
<a href="{% url 'documenti_lista' %}" class="btn btn-outline-secondary">Annulla</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "diario/base.html" %}
{% block title %}Documenti{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
<p class="section-title mb-0">Documenti</p>
<a href="{% url 'documento_nuovo' %}" class="btn btn-primary btn-sm px-3">
<i class="bi bi-upload me-1"></i>Carica documento
</a>
</div>
<div class="row g-3">
{% for doc in documenti %}
<div class="col-12 col-md-6 col-xl-4 fade-in">
<div class="card h-100 p-3">
<div class="d-flex align-items-start gap-3">
<div class="flex-shrink-0">
<div class="d-flex align-items-center justify-content-center rounded-3" style="width:42px;height:42px;background:#fef2f2;">
<i class="bi bi-file-earmark-pdf" style="font-size:1.3rem;color:#ef4444;"></i>
</div>
</div>
<div class="flex-grow-1 min-w-0">
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold d-block text-truncate">
{{ doc.titolo }}
</a>
{% if doc.descrizione %}
<p class="text-muted small mb-1 mt-1">{{ doc.descrizione|truncatewords:15 }}</p>
{% endif %}
<div class="d-flex align-items-center gap-2 flex-wrap mt-2">
<div class="d-flex align-items-center gap-1">
<span class="avatar" style="width:18px;height:18px;font-size:.5rem;">{{ doc.caricato_da.username|slice:":2"|upper }}</span>
<small class="text-muted">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }}</small>
</div>
<small class="text-muted">{{ doc.data_caricamento|date:"d/m/Y" }}</small>
</div>
{% if doc.conversazione %}
<div class="mt-1">
<a href="{% url 'conversazione_dettaglio' doc.conversazione.pk %}" class="small text-decoration-none" style="color:var(--accent);">
<i class="bi bi-link-45deg me-1"></i>{{ doc.conversazione.titolo|truncatewords:5 }}
</a>
</div>
{% elif doc.obiettivo %}
<div class="mt-1">
<a href="{% url 'obiettivo_dettaglio' doc.obiettivo.pk %}" class="small text-decoration-none" style="color:var(--accent);">
<i class="bi bi-link-45deg me-1"></i>{{ doc.obiettivo.titolo|truncatewords:5 }}
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="empty-state">
<i class="bi bi-file-earmark-pdf"></i>
<p>Nessun documento caricato.</p>
<a href="{% url 'documento_nuovo' %}" class="btn btn-primary btn-sm px-3">
<i class="bi bi-upload me-1"></i>Carica il primo
</a>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Olimpic Nastri</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container" style="max-width:420px; margin-top:100px;">
<div class="card shadow-sm p-4">
<h3 class="mb-4 text-center fw-bold">Olimpic Progetto Nastri</h3>
<form method="post">
{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger">Username o password errati.</div>
{% endif %}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" autofocus required>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-dark w-100">Entra</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,174 @@
{% extends "diario/base.html" %}
{% load custom_filters %}
{% block title %}{{ obj.titolo }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Header -->
<div class="d-flex align-items-center mb-4 gap-3 fade-in">
<a href="{% url 'obiettivi_lista' %}" class="btn btn-icon btn-outline-secondary">
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
</a>
<div class="flex-grow-1">
<h5 class="mb-0 fw-bold">{{ obj.titolo }}</h5>
</div>
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
{% if can_edit %}
<a href="{% url 'obiettivo_modifica' obj.pk %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Modifica
</a>
<a href="{% url 'obiettivo_elimina' obj.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Elimina
</a>
{% endif %}
</div>
<!-- Card info -->
<div class="card p-4 mb-4 fade-in">
<div class="row g-3 mb-3">
<div class="col-6 col-sm-3">
<small class="text-muted d-block mb-1">Tipo</small>
<span class="pill-tipo pill-{{ obj.tipo }}">{{ obj.get_tipo_display }}</span>
</div>
{% if obj.assegnato_a.all %}
<div class="col-6 col-sm-3">
<small class="text-muted d-block mb-1">Assegnato a</small>
<div class="d-flex flex-wrap gap-1">
{% for p in obj.assegnato_a.all %}
<div class="d-flex align-items-center gap-1">
<span class="avatar" style="width:22px;height:22px;font-size:.58rem;">{{ p.username|slice:":2"|upper }}</span>
<span class="small fw-semibold">{{ p.get_full_name|default:p.username }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if obj.data_scadenza %}
<div class="col-6 col-sm-3">
<small class="text-muted d-block mb-1">Scadenza</small>
<strong class="small">{{ obj.data_scadenza|date:"d/m/Y" }}</strong>
{% with days=obj.giorni_rimanenti %}
{% if days is not None %}
<div class="mt-1">
{% if days < 0 %}
<span class="countdown countdown-urgent"><i class="bi bi-exclamation-triangle-fill me-1"></i>Scaduto da {{ days|abs_val }} gg</span>
{% elif days == 0 %}
<span class="countdown countdown-urgent">Scade oggi!</span>
{% elif days == 1 %}
<span class="countdown countdown-urgent">Scade domani</span>
{% elif days <= 7 %}
<span class="countdown countdown-soon">{{ days }} giorni rimasti</span>
{% else %}
<span class="countdown countdown-ok">{{ days }} giorni rimasti</span>
{% endif %}
</div>
{% endif %}
{% endwith %}
</div>
{% endif %}
<div class="col-6 col-sm-3">
<small class="text-muted d-block mb-1">Creato da</small>
<span class="small">{{ obj.creato_da.get_full_name|default:obj.creato_da.username }}</span>
</div>
</div>
{% if obj.descrizione %}
<hr class="soft my-3">
<p class="mb-0" style="white-space:pre-wrap;line-height:1.7;">{{ obj.descrizione }}</p>
{% endif %}
<!-- Slider avanzamento AJAX -->
<hr class="soft my-3">
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="fw-semibold text-muted">Avanzamento</small>
<span class="slider-label" id="lbl-{{ obj.pk }}">{{ obj.avanzamento }}%</span>
</div>
<input
type="range" min="0" max="100" step="5"
value="{{ obj.avanzamento }}"
class="progress-slider w-100"
style="--val: {{ obj.avanzamento }}%;"
data-url="{% url 'obiettivo_avanzamento_ajax' obj.pk %}"
data-id="{{ obj.pk }}"
>
<span class="slider-saving d-none" id="saving-{{ obj.pk }}">salvataggio…</span>
</div>
</div>
<!-- Documenti allegati -->
<div class="d-flex justify-content-between align-items-center mb-3 fade-in">
<p class="section-title mb-0"><i class="bi bi-paperclip me-1"></i>Allegati ({{ documenti|length }})</p>
<a href="{% url 'documento_nuovo' %}?obiettivo={{ obj.pk }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-upload me-1"></i>Allega PDF
</a>
</div>
{% for doc in documenti %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-file-earmark-pdf" style="font-size:1.5rem;color:#dc3545;"></i>
<div class="flex-grow-1">
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold small d-block">
{{ doc.titolo }}
</a>
<small class="text-muted">{{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} · {{ doc.data_caricamento|date:"d/m/Y" }}</small>
</div>
<a href="{{ doc.file.url }}" class="btn btn-sm btn-outline-secondary" hx-boost="false" target="_blank">
<i class="bi bi-download"></i>
</a>
</div>
</div>
{% empty %}
<p class="text-muted small text-center py-2 fade-in">Nessun documento allegato.</p>
{% endfor %}
<!-- Aggiornamenti -->
<p class="section-title mt-4 fade-in">Aggiornamenti ({{ aggiornamenti.count }})</p>
<!-- Form nuovo aggiornamento -->
<div class="card p-3 mb-4 fade-in" style="border-left: 3px solid var(--accent) !important;">
<form method="post">
{% csrf_token %}
<label class="form-label fw-semibold small mb-2">Aggiungi un aggiornamento</label>
{{ agg_form.testo }}
<button type="submit" class="btn btn-primary btn-sm mt-2 px-3">
<i class="bi bi-send me-1"></i>Pubblica
</button>
</form>
</div>
{% for agg in aggiornamenti %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex justify-content-between align-items-start">
<div class="d-flex align-items-center gap-2">
<span class="avatar" style="width:28px;height:28px;font-size:.65rem;">{{ agg.autore.username|slice:":2"|upper }}</span>
<span class="fw-semibold small">{{ agg.autore.get_full_name|default:agg.autore.username }}</span>
</div>
<div class="d-flex align-items-center gap-2">
<small class="text-muted">{{ agg.data|date:"d/m/Y H:i" }}</small>
{% if user == agg.autore or user.is_superuser %}
<div class="dropdown">
<button class="btn btn-icon btn-outline-secondary" style="width:24px;height:24px;" data-bs-toggle="dropdown">
<i class="bi bi-three-dots" style="font-size:.7rem;"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0" style="border-radius:10px; min-width:140px;">
<li><a class="dropdown-item small" href="{% url 'aggiornamento_modifica' agg.pk %}"><i class="bi bi-pencil me-2 text-muted"></i>Modifica</a></li>
<li><a class="dropdown-item small text-danger" href="{% url 'aggiornamento_elimina' agg.pk %}"><i class="bi bi-trash me-2"></i>Elimina</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
<p class="mb-0 mt-2 small" style="white-space:pre-wrap;line-height:1.65;padding-left:40px;">{{ agg.testo }}</p>
</div>
{% empty %}
<p class="text-muted small text-center py-3">Nessun aggiornamento ancora.</p>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% extends "diario/base.html" %}
{% block title %}{{ titolo_pagina }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex align-items-center mb-4">
<a href="{% url 'obiettivi_lista' %}" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0">{{ titolo_pagina }}</h4>
</div>
<div class="card p-4">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.titolo.label }}</label>
{{ form.titolo }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ form.descrizione.label }}</label>
{{ form.descrizione }}
</div>
<div class="row g-3 mb-3">
<div class="col-sm-4">
<label class="form-label fw-semibold">{{ form.tipo.label }}</label>
{{ form.tipo }}
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">{{ form.stato.label }}</label>
{{ form.stato }}
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">{{ form.data_scadenza.label }}</label>
{{ form.data_scadenza }}
</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">{{ form.assegnato_a.label }}</label>
<div class="form-text mb-2">Seleziona una o più persone (solo per obiettivi individuali).</div>
<div class="row row-cols-2 row-cols-sm-3 g-2">
{% for checkbox in form.assegnato_a %}
<div class="col">
<div class="form-check">
{{ checkbox.tag }}
<label class="form-check-label" for="{{ checkbox.id_for_label }}">
{{ checkbox.choice_label }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Salva</button>
{% if obj %}
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="btn btn-outline-secondary">Annulla</a>
{% else %}
<a href="{% url 'obiettivi_lista' %}" class="btn btn-outline-secondary">Annulla</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,101 @@
{% extends "diario/base.html" %}
{% load custom_filters %}
{% block title %}Obiettivi{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 fade-in">
<div>
<p class="section-title mb-0">Obiettivi del progetto</p>
</div>
<a href="{% url 'obiettivo_nuovo' %}" class="btn btn-primary btn-sm px-3">
<i class="bi bi-plus-lg me-1"></i>Nuovo obiettivo
</a>
</div>
<!-- Filtri -->
<div class="mb-4 d-flex gap-2 flex-wrap fade-in">
<a href="?filtro=tutti" class="btn btn-sm {% if filtro == 'tutti' %}btn-primary{% else %}btn-outline-secondary{% endif %}">Tutti</a>
<a href="?filtro=collettivi" class="btn btn-sm {% if filtro == 'collettivi' %}btn-primary{% else %}btn-outline-secondary{% endif %}">Collettivi</a>
<a href="?filtro=individuali" class="btn btn-sm {% if filtro == 'individuali' %}btn-primary{% else %}btn-outline-secondary{% endif %}">Individuali</a>
<a href="?filtro=miei" class="btn btn-sm {% if filtro == 'miei' %}btn-primary{% else %}btn-outline-secondary{% endif %}">I miei</a>
</div>
<div class="row g-3">
{% for obj in obiettivi %}
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100 p-3 fade-in">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex gap-2 align-items-center flex-wrap">
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
<span class="pill-tipo pill-{{ obj.tipo }}">{{ obj.get_tipo_display }}</span>
</div>
{% if user == obj.creato_da or user.is_superuser %}
<a href="{% url 'obiettivo_modifica' obj.pk %}" class="btn btn-icon btn-outline-secondary">
<i class="bi bi-pencil" style="font-size:.75rem;"></i>
</a>
{% endif %}
</div>
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold mb-1 d-block">
{{ obj.titolo }}
</a>
{% if obj.descrizione %}
<p class="text-muted small mb-2">{{ obj.descrizione|truncatewords:18 }}</p>
{% endif %}
{% if obj.assegnato_a.all %}
<div class="d-flex align-items-center gap-1 flex-wrap mb-2">
{% for p in obj.assegnato_a.all %}
<span class="avatar" style="width:22px;height:22px;font-size:.58rem;" title="{{ p.get_full_name|default:p.username }}">{{ p.username|slice:":2"|upper }}</span>
{% endfor %}
<small class="text-muted ms-1">{% for p in obj.assegnato_a.all %}{{ p.get_full_name|default:p.username }}{% if not forloop.last %}, {% endif %}{% endfor %}</small>
</div>
{% endif %}
<!-- Progress bar + slider AJAX -->
<div class="mt-auto pt-2">
<div class="d-flex align-items-center gap-2">
<input
type="range" min="0" max="100" step="5"
value="{{ obj.avanzamento }}"
class="progress-slider flex-grow-1"
style="--val: {{ obj.avanzamento }}%;"
data-url="{% url 'obiettivo_avanzamento_ajax' obj.pk %}"
data-id="{{ obj.pk }}"
>
<span class="slider-label" id="lbl-{{ obj.pk }}">{{ obj.avanzamento }}%</span>
</div>
<span class="slider-saving d-none" id="saving-{{ obj.pk }}">salvataggio…</span>
{% if obj.data_scadenza %}
<div class="d-flex align-items-center gap-2 mt-1">
<small class="text-muted"><i class="bi bi-calendar3 me-1"></i>{{ obj.data_scadenza|date:"d/m/Y" }}</small>
{% with days=obj.giorni_rimanenti %}
{% if days is not None %}
{% if days < 0 %}
<span class="countdown countdown-urgent" style="font-size:.65rem;">Scaduto</span>
{% elif days <= 3 %}
<span class="countdown countdown-urgent" style="font-size:.65rem;">{{ days }}gg</span>
{% elif days <= 7 %}
<span class="countdown countdown-soon" style="font-size:.65rem;">{{ days }}gg</span>
{% else %}
<span class="countdown countdown-ok" style="font-size:.65rem;">{{ days }}gg</span>
{% endif %}
{% endif %}
{% endwith %}
</div>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="empty-state">
<i class="bi bi-bullseye"></i>
<p>Nessun obiettivo trovato per questo filtro.</p>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "diario/base.html" %}
{% block title %}{{ persona.get_full_name|default:persona.username }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex align-items-center mb-4 gap-3 fade-in">
<a href="{% url 'persone_lista' %}" class="btn btn-icon btn-outline-secondary">
<i class="bi bi-arrow-left" style="font-size:.85rem;"></i>
</a>
<span class="avatar avatar-lg">{{ persona.username|slice:":2"|upper }}</span>
<div>
<h5 class="mb-0 fw-bold">{{ persona.get_full_name|default:persona.username }}</h5>
<small class="text-muted">@{{ persona.username }}</small>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-pills mb-4 gap-1 fade-in" role="tablist">
<li class="nav-item">
<button class="nav-link active btn-sm" data-bs-toggle="pill" data-bs-target="#tab-conv">
<i class="bi bi-chat-quote me-1"></i>Conversazioni <span class="badge bg-secondary bg-opacity-25 text-dark ms-1">{{ conversazioni|length }}</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link btn-sm" data-bs-toggle="pill" data-bs-target="#tab-obj">
<i class="bi bi-bullseye me-1"></i>Obiettivi <span class="badge bg-secondary bg-opacity-25 text-dark ms-1">{{ obiettivi|length }}</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link btn-sm" data-bs-toggle="pill" data-bs-target="#tab-doc">
<i class="bi bi-file-pdf me-1"></i>Documenti <span class="badge bg-secondary bg-opacity-25 text-dark ms-1">{{ documenti|length }}</span>
</button>
</li>
</ul>
<div class="tab-content">
<!-- Conversazioni -->
<div class="tab-pane fade show active" id="tab-conv">
{% for conv in conversazioni %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex justify-content-between align-items-start">
<div>
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="text-decoration-none text-dark fw-semibold">{{ conv.titolo }}</a>
<div class="text-muted small mt-1">{{ conv.data|date:"d/m/Y H:i" }}</div>
</div>
{% if conv.registrato_da == persona %}
<span class="doc-badge">Autore</span>
{% else %}
<span class="badge-stato stato-aperto">Partecipante</span>
{% endif %}
</div>
</div>
{% empty %}
<div class="empty-state"><i class="bi bi-chat-square-dots"></i><p>Nessuna conversazione.</p></div>
{% endfor %}
</div>
<!-- Obiettivi -->
<div class="tab-pane fade" id="tab-obj">
{% for obj in obiettivi %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex justify-content-between align-items-start">
<div>
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold">{{ obj.titolo }}</a>
<div class="d-flex gap-2 mt-1">
<span class="badge-stato stato-{{ obj.stato }}">{{ obj.get_stato_display }}</span>
<span class="pill-tipo pill-{{ obj.tipo }}">{{ obj.get_tipo_display }}</span>
</div>
</div>
{% if obj.creato_da == persona %}
<span class="doc-badge">Creato</span>
{% else %}
<span class="badge-stato stato-in_corso">Assegnato</span>
{% endif %}
</div>
</div>
{% empty %}
<div class="empty-state"><i class="bi bi-bullseye"></i><p>Nessun obiettivo.</p></div>
{% endfor %}
</div>
<!-- Documenti -->
<div class="tab-pane fade" id="tab-doc">
{% for doc in documenti %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-file-earmark-pdf" style="font-size:1.2rem;color:#ef4444;"></i>
<div>
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold">{{ doc.titolo }}</a>
<div class="text-muted small">{{ doc.data_caricamento|date:"d/m/Y" }}</div>
</div>
</div>
</div>
{% empty %}
<div class="empty-state"><i class="bi bi-file-earmark-pdf"></i><p>Nessun documento.</p></div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "diario/base.html" %}
{% block title %}Persone{% endblock %}
{% block content %}
<p class="section-title fade-in">Team</p>
<div class="row g-3">
{% for persona in persone %}
<div class="col-6 col-md-4 col-lg-3 fade-in">
<a href="{% url 'persona_dettaglio' persona.pk %}" class="card-link">
<div class="card person-card h-100">
<span class="avatar avatar-lg mx-auto">{{ persona.username|slice:":2"|upper }}</span>
<div class="fw-semibold small">{{ persona.get_full_name|default:persona.username }}</div>
<div class="mt-2 d-flex justify-content-center gap-3">
<div class="stat">
<div class="stat-num">{{ persona.num_conversazioni }}</div>
<i class="bi bi-chat-quote"></i>
</div>
<div class="stat">
<div class="stat-num">{{ persona.num_obiettivi }}</div>
<i class="bi bi-bullseye"></i>
</div>
<div class="stat">
<div class="stat-num">{{ persona.num_documenti }}</div>
<i class="bi bi-file-pdf"></i>
</div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends "diario/base.html" %}
{% block title %}Ricerca{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8 fade-in">
<div class="d-flex align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-search me-2" style="color:var(--accent);"></i>Ricerca</h4>
</div>
<form method="get" class="mb-4">
<div class="input-group">
<input type="search" name="q" value="{{ q }}" class="form-control form-control-lg" placeholder="Cerca conversazioni, obiettivi, documenti, persone..." autofocus>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-search"></i>
</button>
</div>
</form>
{% if q %}
<p class="text-muted small mb-4">
{% if total %}{{ total }} risultat{{ total|pluralize:"o,i" }} per <strong>"{{ q }}"</strong>{% else %}Nessun risultato per <strong>"{{ q }}"</strong>{% endif %}
</p>
{% if risultati.persone %}
<p class="section-title"><i class="bi bi-people me-1"></i>Persone</p>
<div class="row g-2 mb-4">
{% for persona in risultati.persone %}
<div class="col-6 col-md-4 fade-in">
<a href="{% url 'persona_dettaglio' persona.pk %}" class="card-link">
<div class="card p-3">
<div class="d-flex align-items-center gap-2">
<span class="avatar" style="width:24px;height:24px;font-size:.6rem;">{{ persona.username|slice:":2"|upper }}</span>
<span class="fw-semibold small">{{ persona.get_full_name|default:persona.username }}</span>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if risultati.conversazioni %}
<p class="section-title"><i class="bi bi-chat-quote me-1"></i>Conversazioni</p>
{% for conv in risultati.conversazioni %}
<div class="card mb-2 p-3 fade-in">
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="text-decoration-none text-dark fw-semibold">{{ conv.titolo }}</a>
<div class="d-flex gap-2 mt-1">
<small class="text-muted">{{ conv.data|date:"d/m/Y" }}</small>
{% if conv.registrato_da %}
<small class="text-muted">· {{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }}</small>
{% endif %}
</div>
<p class="text-muted small mb-0 mt-1">{{ conv.contenuto|truncatewords:25 }}</p>
</div>
{% endfor %}
<div class="mb-4"></div>
{% endif %}
{% if risultati.obiettivi %}
<p class="section-title"><i class="bi bi-bullseye me-1"></i>Obiettivi</p>
{% for obj in risultati.obiettivi %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex align-items-start justify-content-between">
<div>
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="text-decoration-none text-dark fw-semibold">{{ obj.titolo }}</a>
{% if obj.descrizione %}
<p class="text-muted small mb-0 mt-1">{{ obj.descrizione|truncatewords:20 }}</p>
{% endif %}
</div>
<span class="badge-stato stato-{{ obj.stato }} ms-2">{{ obj.get_stato_display }}</span>
</div>
</div>
{% endfor %}
<div class="mb-4"></div>
{% endif %}
{% if risultati.documenti %}
<p class="section-title"><i class="bi bi-file-earmark-pdf me-1"></i>Documenti</p>
{% for doc in risultati.documenti %}
<div class="card mb-2 p-3 fade-in">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-file-earmark-pdf" style="color:#ef4444;"></i>
<div>
<a href="{% url 'documento_dettaglio' doc.pk %}" class="text-decoration-none text-dark fw-semibold">{{ doc.titolo }}</a>
{% if doc.descrizione %}<p class="text-muted small mb-0 mt-1">{{ doc.descrizione|truncatewords:15 }}</p>{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% if total == 0 and q %}
<div class="p-3 text-center text-muted small">Nessun risultato per "{{ q }}"</div>
{% endif %}
{% for persona in risultati.persone %}
<a href="{% url 'persona_dettaglio' persona.pk %}" class="result-item">
<div class="result-type">Persona</div>
<div class="d-flex align-items-center gap-2">
<span class="avatar" style="width:20px;height:20px;font-size:.55rem;">{{ persona.username|slice:":2"|upper }}</span>
<span class="fw-semibold small">{{ persona.get_full_name|default:persona.username }}</span>
</div>
</a>
{% endfor %}
{% for conv in risultati.conversazioni|slice:":5" %}
<a href="{% url 'conversazione_dettaglio' conv.pk %}" class="result-item">
<div class="result-type">Conversazione</div>
<span class="small">{{ conv.titolo }}</span>
</a>
{% endfor %}
{% for obj in risultati.obiettivi|slice:":5" %}
<a href="{% url 'obiettivo_dettaglio' obj.pk %}" class="result-item">
<div class="result-type">Obiettivo</div>
<span class="small">{{ obj.titolo }}</span>
</a>
{% endfor %}
{% for doc in risultati.documenti|slice:":5" %}
<a href="{% url 'documento_dettaglio' doc.pk %}" class="result-item">
<div class="result-type">Documento</div>
<span class="small">{{ doc.titolo }}</span>
</a>
{% endfor %}
{% if total > 0 %}
<a href="{% url 'ricerca' %}?q={{ q|urlencode }}" class="result-item text-center" style="color:var(--accent);font-weight:600;font-size:.8rem;">
Vedi tutti i {{ total }} risultati →
</a>
{% endif %}