commit d296353dcbf0d222c68cf304d648ed23333a5138 Author: automationkriz Date: Sun Apr 5 14:48:22 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..088dbfc --- /dev/null +++ b/.gitignore @@ -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 +*~ diff --git a/UTENTI.txt b/UTENTI.txt new file mode 100644 index 0000000..74c3d1e --- /dev/null +++ b/UTENTI.txt @@ -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/ diff --git a/diario.olimpic.click.nginx b/diario.olimpic.click.nginx new file mode 100644 index 0000000..a46de34 --- /dev/null +++ b/diario.olimpic.click.nginx @@ -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; +} diff --git a/diario/__init__.py b/diario/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/diario/admin.py b/diario/admin.py new file mode 100644 index 0000000..5a48ba1 --- /dev/null +++ b/diario/admin.py @@ -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',) + diff --git a/diario/apps.py b/diario/apps.py new file mode 100644 index 0000000..c0e9fc4 --- /dev/null +++ b/diario/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DiarioConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'diario' diff --git a/diario/forms.py b/diario/forms.py new file mode 100644 index 0000000..92496e7 --- /dev/null +++ b/diario/forms.py @@ -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)', + } diff --git a/diario/migrations/0001_initial.py b/diario/migrations/0001_initial.py new file mode 100644 index 0000000..fd80043 --- /dev/null +++ b/diario/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/diario/migrations/0002_obiettivo_avanzamento.py b/diario/migrations/0002_obiettivo_avanzamento.py new file mode 100644 index 0000000..7a06158 --- /dev/null +++ b/diario/migrations/0002_obiettivo_avanzamento.py @@ -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), + ), + ] diff --git a/diario/migrations/0003_alter_conversazione_data_and_more.py b/diario/migrations/0003_alter_conversazione_data_and_more.py new file mode 100644 index 0000000..fe7a663 --- /dev/null +++ b/diario/migrations/0003_alter_conversazione_data_and_more.py @@ -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), + ), + ] diff --git a/diario/migrations/0004_documento.py b/diario/migrations/0004_documento.py new file mode 100644 index 0000000..1f69c4a --- /dev/null +++ b/diario/migrations/0004_documento.py @@ -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'], + }, + ), + ] diff --git a/diario/migrations/0005_commento_conversazione.py b/diario/migrations/0005_commento_conversazione.py new file mode 100644 index 0000000..5aa2c76 --- /dev/null +++ b/diario/migrations/0005_commento_conversazione.py @@ -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'], + }, + ), + ] diff --git a/diario/migrations/__init__.py b/diario/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/diario/models.py b/diario/models.py new file mode 100644 index 0000000..8e731ca --- /dev/null +++ b/diario/models.py @@ -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) + diff --git a/diario/templatetags/__init__.py b/diario/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/diario/templatetags/custom_filters.py b/diario/templatetags/custom_filters.py new file mode 100644 index 0000000..f98d105 --- /dev/null +++ b/diario/templatetags/custom_filters.py @@ -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 diff --git a/diario/tests.py b/diario/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/diario/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/diario/urls.py b/diario/urls.py new file mode 100644 index 0000000..5b20c06 --- /dev/null +++ b/diario/urls.py @@ -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//', views.conversazione_dettaglio, name='conversazione_dettaglio'), + path('conversazioni//modifica/', views.conversazione_modifica, name='conversazione_modifica'), + path('conversazioni//elimina/', views.conversazione_elimina, name='conversazione_elimina'), + + # Commenti conversazioni + path('commenti//modifica/', views.commento_modifica, name='commento_modifica'), + path('commenti//elimina/', views.commento_elimina, name='commento_elimina'), + + # Aggiornamenti obiettivi + path('aggiornamenti//modifica/', views.aggiornamento_modifica, name='aggiornamento_modifica'), + path('aggiornamenti//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//', views.obiettivo_dettaglio, name='obiettivo_dettaglio'), + path('obiettivi//modifica/', views.obiettivo_modifica, name='obiettivo_modifica'), + path('obiettivi//elimina/', views.obiettivo_elimina, name='obiettivo_elimina'), + path('obiettivi//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//', views.documento_dettaglio, name='documento_dettaglio'), + path('documenti//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//', views.persona_dettaglio, name='persona_dettaglio'), +] diff --git a/diario/views.py b/diario/views.py new file mode 100644 index 0000000..a6c58f4 --- /dev/null +++ b/diario/views.py @@ -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, + }) + diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..4a00f76 --- /dev/null +++ b/manage.py @@ -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() diff --git a/olimpic-nastri-gunicorn.service b/olimpic-nastri-gunicorn.service new file mode 100644 index 0000000..41883ba --- /dev/null +++ b/olimpic-nastri-gunicorn.service @@ -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 diff --git a/olimpic_nastri/__init__.py b/olimpic_nastri/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/olimpic_nastri/asgi.py b/olimpic_nastri/asgi.py new file mode 100644 index 0000000..551d8eb --- /dev/null +++ b/olimpic_nastri/asgi.py @@ -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() diff --git a/olimpic_nastri/settings.py b/olimpic_nastri/settings.py new file mode 100644 index 0000000..efdfedc --- /dev/null +++ b/olimpic_nastri/settings.py @@ -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 diff --git a/olimpic_nastri/urls.py b/olimpic_nastri/urls.py new file mode 100644 index 0000000..a736d3c --- /dev/null +++ b/olimpic_nastri/urls.py @@ -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) + diff --git a/olimpic_nastri/wsgi.py b/olimpic_nastri/wsgi.py new file mode 100644 index 0000000..01d5263 --- /dev/null +++ b/olimpic_nastri/wsgi.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ca0a78 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..3ac75aa --- /dev/null +++ b/static/css/style.css @@ -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; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..991bd60 --- /dev/null +++ b/static/js/app.js @@ -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 = ` + `; + 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); diff --git a/templates/diario/agenda.html b/templates/diario/agenda.html new file mode 100644 index 0000000..acd86af --- /dev/null +++ b/templates/diario/agenda.html @@ -0,0 +1,164 @@ +{% extends "diario/base.html" %} +{% load custom_filters %} +{% block title %}Agenda – Olimpic Nastri{% endblock %} + +{% block content %} +
+
+ +
+
+

Agenda

+ Panoramica di scadenze e appuntamenti +
+ +
+ + +
+
+ + Prossimi eventi + {{ eventi_futuri|length }} +
+
+ {% for ev in eventi_futuri %} +
+ +
+
{{ ev.data|date:"d" }}
+
{{ ev.data|date:"M" }}
+
+ + + {% if ev.tipo == 'scadenza' %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + + +
+ {% if ev.tipo == 'scadenza' %} + + {{ ev.obj.titolo }} + +
+ {{ ev.obj.get_stato_display }} + {{ ev.obj.get_tipo_display }} + {% with days=ev.obj.giorni_rimanenti %} + {% if days == 0 %} + Oggi! + {% elif days == 1 %} + Domani + {% elif days <= 7 %} + {{ days }} giorni + {% else %} + {{ days }} giorni + {% endif %} + {% endwith %} +
+ {% else %} + + {{ ev.obj.titolo }} + +
+ + {{ ev.obj.data|date:"H:i" }} + + + {{ ev.obj.registrato_da.get_full_name|default:ev.obj.registrato_da.username }} + +
+ {% endif %} +
+ + + {% if ev.tipo == 'scadenza' %} +
+
+
+
+
+ {{ ev.obj.avanzamento }}% +
+ {% endif %} +
+ {% empty %} +
+ +

Nessun evento programmato. Tutto in ordine!

+
+ {% endfor %} +
+
+ + +
+
+ + Ultimi 30 giorni + {{ eventi_passati|length }} +
+
+ {% for ev in eventi_passati %} +
+ +
+
{{ ev.data|date:"d" }}
+
{{ ev.data|date:"M" }}
+
+ + + {% if ev.tipo == 'scadenza' %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + + +
+ {% if ev.tipo == 'scadenza' %} + + {{ ev.obj.titolo }} + {% if ev.scaduto %}(scaduto){% endif %} + +
+ {{ ev.obj.get_stato_display }} +
+ {% else %} + + {{ ev.obj.titolo }} + + {{ ev.obj.registrato_da.get_full_name|default:ev.obj.registrato_da.username }} + {% endif %} +
+
+ {% empty %} +
+ +

Nessun evento negli ultimi 30 giorni.

+
+ {% endfor %} +
+
+ +
+
+{% endblock %} diff --git a/templates/diario/base.html b/templates/diario/base.html new file mode 100644 index 0000000..54177f9 --- /dev/null +++ b/templates/diario/base.html @@ -0,0 +1,120 @@ +{% load static %} + + + + + + {% block title %}Olimpic Nastri{% endblock %} + + + + + + + + +
+ {% if messages %} + {% for message in messages %} +
+ {% endfor %} + {% endif %} + + {% block content %}{% endblock %} +
+ + +
+ + + + + +{% block extra_js %}{% endblock %} + + + diff --git a/templates/diario/commento_modifica.html b/templates/diario/commento_modifica.html new file mode 100644 index 0000000..b92b894 --- /dev/null +++ b/templates/diario/commento_modifica.html @@ -0,0 +1,33 @@ +{% extends "diario/base.html" %} +{% block title %}Modifica {{ tipo }}{% endblock %} + +{% block content %} +
+
+ +
+ + + +
Modifica {{ tipo }}
+
+ +
+
+ {% csrf_token %} +
+ + {{ form.testo }} +
+
+ + Annulla +
+
+
+ +
+
+{% endblock %} diff --git a/templates/diario/conferma_elimina.html b/templates/diario/conferma_elimina.html new file mode 100644 index 0000000..7838e72 --- /dev/null +++ b/templates/diario/conferma_elimina.html @@ -0,0 +1,30 @@ +{% extends "diario/base.html" %} +{% block title %}Conferma eliminazione{% endblock %} + +{% block content %} +
+
+ +
+
+ +
+
Elimina {{ tipo }}
+

+ Stai per eliminare {{ oggetto }}.
+ Questa azione è irreversibile. +

+
+
+ {% csrf_token %} + +
+ Annulla +
+
+ +
+
+{% endblock %} diff --git a/templates/diario/conversazioni/dettaglio.html b/templates/diario/conversazioni/dettaglio.html new file mode 100644 index 0000000..dafb0b3 --- /dev/null +++ b/templates/diario/conversazioni/dettaglio.html @@ -0,0 +1,122 @@ +{% extends "diario/base.html" %} +{% block title %}{{ conv.titolo }}{% endblock %} + +{% block content %} +
+
+ +
+ + + +
{{ conv.titolo }}
+ {% if can_edit %} + + Modifica + + + Elimina + + {% endif %} +
+ +
+
+ {{ conv.registrato_da.username|slice:":2"|upper }} +
+
{{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }}
+ {{ conv.data|date:"d/m/Y \a\l\l\e H:i" }} +
+
+ +
{{ conv.contenuto }}
+ + {% if conv.partecipanti.all %} +
+
+ Partecipanti +
+ {% for p in conv.partecipanti.all %} +
+ {{ p.username|slice:":2"|upper }} + {{ p.get_full_name|default:p.username }} +
+ {% endfor %} +
+
+ {% endif %} +
+ + +
+

Allegati ({{ documenti|length }})

+ + Allega PDF + +
+ + {% for doc in documenti %} +
+
+ +
+ + {{ doc.titolo }} + + {{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} · {{ doc.data_caricamento|date:"d/m/Y" }} +
+ + + +
+
+ {% empty %} +

Nessun documento allegato.

+ {% endfor %} + + +

Commenti ({{ commenti|length }})

+ + +
+
+ {% csrf_token %} + + {{ comment_form.testo }} + +
+
+ + {% for c in commenti %} +
+
+
+ {{ c.autore.username|slice:":2"|upper }} + {{ c.autore.get_full_name|default:c.autore.username }} +
+
+ {{ c.data|date:"d/m/Y H:i" }} + {% if user == c.autore or user.is_superuser %} + + {% endif %} +
+
+

{{ c.testo }}

+
+ {% empty %} +

Nessun commento ancora. Sii il primo!

+ {% endfor %} + +
+
+{% endblock %} diff --git a/templates/diario/conversazioni/form.html b/templates/diario/conversazioni/form.html new file mode 100644 index 0000000..7371ba2 --- /dev/null +++ b/templates/diario/conversazioni/form.html @@ -0,0 +1,57 @@ +{% extends "diario/base.html" %} +{% block title %}{{ titolo_pagina }}{% endblock %} + +{% block content %} +
+
+
+ + + +

{{ titolo_pagina }}

+
+ +
+
+ {% csrf_token %} +
+ + {{ form.titolo }} +
+
+ + {{ form.data }} +
Puoi inserire una data passata per conversazioni già avvenute.
+
+
+ + {{ form.contenuto }} +
+
+ +
+ {% for checkbox in form.partecipanti %} +
+
+ {{ checkbox.tag }} + +
+
+ {% endfor %} +
+
+
+ + {% if conv %} + Annulla + {% else %} + Annulla + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/templates/diario/conversazioni/lista.html b/templates/diario/conversazioni/lista.html new file mode 100644 index 0000000..b2b0f1b --- /dev/null +++ b/templates/diario/conversazioni/lista.html @@ -0,0 +1,44 @@ +{% extends "diario/base.html" %} +{% block title %}Conversazioni{% endblock %} + +{% block content %} +
+

Cronologia conversazioni

+ + Nuova conversazione + +
+ +{% for conv in conversazioni %} +
+
+
+ + {{ conv.titolo }} + +
+ {{ conv.data|date:"d/m/Y H:i" }} +
+ {{ conv.registrato_da.username|slice:":2"|upper }} + {{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }} +
+ {% if conv.partecipanti.count > 0 %} + {{ conv.partecipanti.count }} + {% endif %} +
+

{{ conv.contenuto|truncatewords:30 }}

+
+ {% if user == conv.registrato_da or user.is_superuser %} + + + + {% endif %} +
+
+{% empty %} +
+ +

Nessuna conversazione registrata.

+
+{% endfor %} +{% endblock %} diff --git a/templates/diario/dashboard.html b/templates/diario/dashboard.html new file mode 100644 index 0000000..88582c2 --- /dev/null +++ b/templates/diario/dashboard.html @@ -0,0 +1,237 @@ +{% extends "diario/base.html" %} +{% load custom_filters %} +{% block title %}Dashboard – Olimpic Nastri{% endblock %} + +{% block content %} +
+ + +
+
+

Cronologia attività

+ + Nuova conversazione + +
+ +
+ {% for evento in eventi %} +
+ {% if evento.tipo == 'conversazione' %} + +
+
+
+ + Conversazione + + +
+ {{ evento.data|date:"d/m H:i" }} +
+

{{ evento.obj.contenuto|truncatewords:25 }}

+
+ + {{ evento.obj.registrato_da.username|slice:":2"|upper }} + + {{ evento.obj.registrato_da.get_full_name|default:evento.obj.registrato_da.username }} + {% if evento.obj.partecipanti.count > 0 %} + · {{ evento.obj.partecipanti.count }} partecipanti + {% endif %} +
+
+ {% elif evento.tipo == 'documento' %} + +
+
+
+ + Documento + + +
+ {{ evento.data|date:"d/m H:i" }} +
+ {% if evento.obj.descrizione %} +

{{ evento.obj.descrizione|truncatewords:20 }}

+ {% endif %} +
+ + {{ evento.obj.caricato_da.username|slice:":2"|upper }} + + {{ evento.obj.caricato_da.get_full_name|default:evento.obj.caricato_da.username }} +
+
+ {% else %} + +
+
+
+ + Aggiornamento obiettivo + + +
+ {{ evento.data|date:"d/m H:i" }} +
+

{{ evento.obj.testo|truncatewords:25 }}

+
+ + {{ evento.obj.autore.username|slice:":2"|upper }} + + {{ evento.obj.autore.get_full_name|default:evento.obj.autore.username }} +
+
+ {% endif %} +
+ {% empty %} +
+ +

Nessuna attività ancora. Inizia registrando una conversazione.

+
+ {% endfor %} +
+
+ + +
+ + +
+
+ Obiettivi in corso + Vedi tutti → +
+
+ {% for obj in obiettivi_aperti %} +
+
+ + {{ obj.titolo }} + + {{ obj.get_stato_display }} +
+ {% if obj.data_scadenza %} +
+ {% with days=obj.giorni_rimanenti %} + {% if days is not None %} + {% if days < 0 %} + Scaduto da {{ days|abs_val }} gg + {% elif days == 0 %} + Scade oggi! + {% elif days <= 3 %} + {{ days }} gg rimasti + {% elif days <= 7 %} + {{ days }} gg rimasti + {% else %} + {{ days }} gg · {{ obj.data_scadenza|date:"d/m" }} + {% endif %} + {% endif %} + {% endwith %} +
+ {% endif %} + +
+ + {{ obj.avanzamento }}% +
+ salvataggio… +
+ {% empty %} +

Tutti gli obiettivi sono completati!

+ {% endfor %} + +
+
+ + +
+
+ Prossime scadenze + Agenda → +
+
+ {% for obj in scadenze_prossime %} +
+
+
{{ obj.data_scadenza|date:"d" }}
+
{{ obj.data_scadenza|date:"M" }}
+
+
+ + {{ obj.titolo|truncatewords:8 }} + + {% with days=obj.giorni_rimanenti %} + {% if days <= 3 %} + {{ days }} gg + {% elif days <= 7 %} + {{ days }} gg + {% else %} + {{ days }} gg + {% endif %} + {% endwith %} +
+
+
+
+
+
+
+ {% empty %} +

Nessuna scadenza prossima

+ {% endfor %} +
+
+ + {% if scaduti %} + +
+
+ + Obiettivi scaduti + {{ scaduti|length }} +
+
+ {% for obj in scaduti %} +
+ +
+ + {{ obj.titolo|truncatewords:8 }} + + Scaduto il {{ obj.data_scadenza|date:"d/m/Y" }} +
+
+ {% endfor %} +
+
+ {% endif %} + +
+ +
+{% endblock %} + diff --git a/templates/diario/documenti/dettaglio.html b/templates/diario/documenti/dettaglio.html new file mode 100644 index 0000000..48a5d16 --- /dev/null +++ b/templates/diario/documenti/dettaglio.html @@ -0,0 +1,70 @@ +{% extends "diario/base.html" %} +{% block title %}{{ doc.titolo }}{% endblock %} + +{% block content %} +
+
+ +
+ + + +
{{ doc.titolo }}
+ {% if can_edit %} + + Elimina + + {% endif %} +
+ +
+
+
+ +
+
+
{{ doc.filename }}
+ Caricato il {{ doc.data_caricamento|date:"d/m/Y \a\l\l\e H:i" }} +
+
+ + + Scarica PDF + + + {% if doc.descrizione %} +
+

{{ doc.descrizione }}

+ {% endif %} + +
+
+
+ Caricato da +
+ {{ doc.caricato_da.username|slice:":2"|upper }} + {{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} +
+
+ {% if doc.conversazione %} + + {% endif %} + {% if doc.obiettivo %} + + {% endif %} +
+
+ +
+
+{% endblock %} diff --git a/templates/diario/documenti/form.html b/templates/diario/documenti/form.html new file mode 100644 index 0000000..fe314f7 --- /dev/null +++ b/templates/diario/documenti/form.html @@ -0,0 +1,53 @@ +{% extends "diario/base.html" %} +{% block title %}{{ titolo_pagina }}{% endblock %} + +{% block content %} +
+
+
+ + + +

{{ titolo_pagina }}

+
+ +
+
+ {% csrf_token %} +
+ + {{ form.titolo }} +
+
+ + {{ form.descrizione }} +
+
+ + {{ form.file }} +
Solo file PDF, massimo 10 MB.
+ {% if form.file.errors %} +
{{ form.file.errors.0 }}
+ {% endif %} +
+
+
+ + {{ form.conversazione }} +
+
+ + {{ form.obiettivo }} +
+
+
+ + Annulla +
+
+
+
+
+{% endblock %} diff --git a/templates/diario/documenti/lista.html b/templates/diario/documenti/lista.html new file mode 100644 index 0000000..32d0527 --- /dev/null +++ b/templates/diario/documenti/lista.html @@ -0,0 +1,65 @@ +{% extends "diario/base.html" %} +{% block title %}Documenti{% endblock %} + +{% block content %} +
+

Documenti

+ + Carica documento + +
+ +
+{% for doc in documenti %} +
+
+
+
+
+ +
+
+
+ + {{ doc.titolo }} + + {% if doc.descrizione %} +

{{ doc.descrizione|truncatewords:15 }}

+ {% endif %} +
+
+ {{ doc.caricato_da.username|slice:":2"|upper }} + {{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} +
+ {{ doc.data_caricamento|date:"d/m/Y" }} +
+ {% if doc.conversazione %} + + {% elif doc.obiettivo %} + + {% endif %} +
+
+
+
+{% empty %} +
+
+ +

Nessun documento caricato.

+ + Carica il primo + +
+
+{% endfor %} +
+{% endblock %} diff --git a/templates/diario/login.html b/templates/diario/login.html new file mode 100644 index 0000000..29552a4 --- /dev/null +++ b/templates/diario/login.html @@ -0,0 +1,32 @@ + + + + + + Login – Olimpic Nastri + + + +
+
+

Olimpic Progetto Nastri

+
+ {% csrf_token %} + {% if form.errors %} +
Username o password errati.
+ {% endif %} +
+ + +
+
+ + +
+ +
+
+
+ + + diff --git a/templates/diario/obiettivi/dettaglio.html b/templates/diario/obiettivi/dettaglio.html new file mode 100644 index 0000000..58d4a0b --- /dev/null +++ b/templates/diario/obiettivi/dettaglio.html @@ -0,0 +1,174 @@ +{% extends "diario/base.html" %} +{% load custom_filters %} +{% block title %}{{ obj.titolo }}{% endblock %} + +{% block content %} +
+
+ + +
+ + + +
+
{{ obj.titolo }}
+
+ {{ obj.get_stato_display }} + {% if can_edit %} + + Modifica + + + Elimina + + {% endif %} +
+ + +
+
+
+ Tipo + {{ obj.get_tipo_display }} +
+ {% if obj.assegnato_a.all %} +
+ Assegnato a +
+ {% for p in obj.assegnato_a.all %} +
+ {{ p.username|slice:":2"|upper }} + {{ p.get_full_name|default:p.username }} +
+ {% endfor %} +
+
+ {% endif %} + {% if obj.data_scadenza %} +
+ Scadenza + {{ obj.data_scadenza|date:"d/m/Y" }} + {% with days=obj.giorni_rimanenti %} + {% if days is not None %} +
+ {% if days < 0 %} + Scaduto da {{ days|abs_val }} gg + {% elif days == 0 %} + Scade oggi! + {% elif days == 1 %} + Scade domani + {% elif days <= 7 %} + {{ days }} giorni rimasti + {% else %} + {{ days }} giorni rimasti + {% endif %} +
+ {% endif %} + {% endwith %} +
+ {% endif %} +
+ Creato da + {{ obj.creato_da.get_full_name|default:obj.creato_da.username }} +
+
+ + {% if obj.descrizione %} +
+

{{ obj.descrizione }}

+ {% endif %} + + +
+
+
+ Avanzamento + {{ obj.avanzamento }}% +
+ + salvataggio… +
+
+ + +
+

Allegati ({{ documenti|length }})

+ + Allega PDF + +
+ + {% for doc in documenti %} +
+
+ +
+ + {{ doc.titolo }} + + {{ doc.caricato_da.get_full_name|default:doc.caricato_da.username }} · {{ doc.data_caricamento|date:"d/m/Y" }} +
+ + + +
+
+ {% empty %} +

Nessun documento allegato.

+ {% endfor %} + + +

Aggiornamenti ({{ aggiornamenti.count }})

+ + +
+
+ {% csrf_token %} + + {{ agg_form.testo }} + +
+
+ + {% for agg in aggiornamenti %} +
+
+
+ {{ agg.autore.username|slice:":2"|upper }} + {{ agg.autore.get_full_name|default:agg.autore.username }} +
+
+ {{ agg.data|date:"d/m/Y H:i" }} + {% if user == agg.autore or user.is_superuser %} + + {% endif %} +
+
+

{{ agg.testo }}

+
+ {% empty %} +

Nessun aggiornamento ancora.

+ {% endfor %} + +
+
+{% endblock %} + diff --git a/templates/diario/obiettivi/form.html b/templates/diario/obiettivi/form.html new file mode 100644 index 0000000..d133ea8 --- /dev/null +++ b/templates/diario/obiettivi/form.html @@ -0,0 +1,67 @@ +{% extends "diario/base.html" %} +{% block title %}{{ titolo_pagina }}{% endblock %} + +{% block content %} +
+
+
+ + + +

{{ titolo_pagina }}

+
+ +
+
+ {% csrf_token %} +
+ + {{ form.titolo }} +
+
+ + {{ form.descrizione }} +
+
+
+ + {{ form.tipo }} +
+
+ + {{ form.stato }} +
+
+ + {{ form.data_scadenza }} +
+
+
+ +
Seleziona una o più persone (solo per obiettivi individuali).
+
+ {% for checkbox in form.assegnato_a %} +
+
+ {{ checkbox.tag }} + +
+
+ {% endfor %} +
+
+
+ + {% if obj %} + Annulla + {% else %} + Annulla + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/templates/diario/obiettivi/lista.html b/templates/diario/obiettivi/lista.html new file mode 100644 index 0000000..33bc920 --- /dev/null +++ b/templates/diario/obiettivi/lista.html @@ -0,0 +1,101 @@ +{% extends "diario/base.html" %} +{% load custom_filters %} +{% block title %}Obiettivi{% endblock %} + +{% block content %} +
+
+

Obiettivi del progetto

+
+ + Nuovo obiettivo + +
+ + + + +
+{% for obj in obiettivi %} +
+
+
+
+ {{ obj.get_stato_display }} + {{ obj.get_tipo_display }} +
+ {% if user == obj.creato_da or user.is_superuser %} + + + + {% endif %} +
+ + + {{ obj.titolo }} + + + {% if obj.descrizione %} +

{{ obj.descrizione|truncatewords:18 }}

+ {% endif %} + + {% if obj.assegnato_a.all %} +
+ {% for p in obj.assegnato_a.all %} + {{ p.username|slice:":2"|upper }} + {% endfor %} + {% for p in obj.assegnato_a.all %}{{ p.get_full_name|default:p.username }}{% if not forloop.last %}, {% endif %}{% endfor %} +
+ {% endif %} + + +
+
+ + {{ obj.avanzamento }}% +
+ salvataggio… + {% if obj.data_scadenza %} +
+ {{ obj.data_scadenza|date:"d/m/Y" }} + {% with days=obj.giorni_rimanenti %} + {% if days is not None %} + {% if days < 0 %} + Scaduto + {% elif days <= 3 %} + {{ days }}gg + {% elif days <= 7 %} + {{ days }}gg + {% else %} + {{ days }}gg + {% endif %} + {% endif %} + {% endwith %} +
+ {% endif %} +
+
+
+{% empty %} +
+
+ +

Nessun obiettivo trovato per questo filtro.

+
+
+{% endfor %} +
+{% endblock %} + diff --git a/templates/diario/persone/dettaglio.html b/templates/diario/persone/dettaglio.html new file mode 100644 index 0000000..089317d --- /dev/null +++ b/templates/diario/persone/dettaglio.html @@ -0,0 +1,104 @@ +{% extends "diario/base.html" %} +{% block title %}{{ persona.get_full_name|default:persona.username }}{% endblock %} + +{% block content %} +
+
+ +
+ + + + {{ persona.username|slice:":2"|upper }} +
+
{{ persona.get_full_name|default:persona.username }}
+ @{{ persona.username }} +
+
+ + + + +
+ +
+ {% for conv in conversazioni %} +
+
+
+ {{ conv.titolo }} +
{{ conv.data|date:"d/m/Y H:i" }}
+
+ {% if conv.registrato_da == persona %} + Autore + {% else %} + Partecipante + {% endif %} +
+
+ {% empty %} +

Nessuna conversazione.

+ {% endfor %} +
+ + +
+ {% for obj in obiettivi %} +
+
+
+ {{ obj.titolo }} +
+ {{ obj.get_stato_display }} + {{ obj.get_tipo_display }} +
+
+ {% if obj.creato_da == persona %} + Creato + {% else %} + Assegnato + {% endif %} +
+
+ {% empty %} +

Nessun obiettivo.

+ {% endfor %} +
+ + +
+ {% for doc in documenti %} +
+
+ +
+ {{ doc.titolo }} +
{{ doc.data_caricamento|date:"d/m/Y" }}
+
+
+
+ {% empty %} +

Nessun documento.

+ {% endfor %} +
+
+ +
+
+{% endblock %} diff --git a/templates/diario/persone/lista.html b/templates/diario/persone/lista.html new file mode 100644 index 0000000..b54b8b6 --- /dev/null +++ b/templates/diario/persone/lista.html @@ -0,0 +1,33 @@ +{% extends "diario/base.html" %} +{% block title %}Persone{% endblock %} + +{% block content %} +

Team

+ + +{% endblock %} diff --git a/templates/diario/ricerca.html b/templates/diario/ricerca.html new file mode 100644 index 0000000..d040b5a --- /dev/null +++ b/templates/diario/ricerca.html @@ -0,0 +1,98 @@ +{% extends "diario/base.html" %} +{% block title %}Ricerca{% endblock %} + +{% block content %} +
+
+ +
+

Ricerca

+
+ +
+
+ + +
+
+ + {% if q %} +

+ {% if total %}{{ total }} risultat{{ total|pluralize:"o,i" }} per "{{ q }}"{% else %}Nessun risultato per "{{ q }}"{% endif %} +

+ + {% if risultati.persone %} +

Persone

+ + {% endif %} + + {% if risultati.conversazioni %} +

Conversazioni

+ {% for conv in risultati.conversazioni %} +
+ {{ conv.titolo }} +
+ {{ conv.data|date:"d/m/Y" }} + {% if conv.registrato_da %} + · {{ conv.registrato_da.get_full_name|default:conv.registrato_da.username }} + {% endif %} +
+

{{ conv.contenuto|truncatewords:25 }}

+
+ {% endfor %} +
+ {% endif %} + + {% if risultati.obiettivi %} +

Obiettivi

+ {% for obj in risultati.obiettivi %} +
+
+
+ {{ obj.titolo }} + {% if obj.descrizione %} +

{{ obj.descrizione|truncatewords:20 }}

+ {% endif %} +
+ {{ obj.get_stato_display }} +
+
+ {% endfor %} +
+ {% endif %} + + {% if risultati.documenti %} +

Documenti

+ {% for doc in risultati.documenti %} +
+
+ +
+ {{ doc.titolo }} + {% if doc.descrizione %}

{{ doc.descrizione|truncatewords:15 }}

{% endif %} +
+
+
+ {% endfor %} + {% endif %} + + {% endif %} + +
+
+{% endblock %} diff --git a/templates/diario/ricerca_risultati.html b/templates/diario/ricerca_risultati.html new file mode 100644 index 0000000..36fd5ac --- /dev/null +++ b/templates/diario/ricerca_risultati.html @@ -0,0 +1,40 @@ +{% if total == 0 and q %} +
Nessun risultato per "{{ q }}"
+{% endif %} + +{% for persona in risultati.persone %} + +
Persona
+
+ {{ persona.username|slice:":2"|upper }} + {{ persona.get_full_name|default:persona.username }} +
+
+{% endfor %} + +{% for conv in risultati.conversazioni|slice:":5" %} + +
Conversazione
+ {{ conv.titolo }} +
+{% endfor %} + +{% for obj in risultati.obiettivi|slice:":5" %} + +
Obiettivo
+ {{ obj.titolo }} +
+{% endfor %} + +{% for doc in risultati.documenti|slice:":5" %} + +
Documento
+ {{ doc.titolo }} +
+{% endfor %} + +{% if total > 0 %} + + Vedi tutti i {{ total }} risultati → + +{% endif %}