diff --git a/.gitignore b/.gitignore index 5772af0..cf57a21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ -.venv +# Python & Django __pycache__/ -db.sqlite3 +*.py[cod] +*.sqlite3 +.env + +# Docker & Volume +postgres_data/ + +# VS Code +.vscode/ diff --git a/equipment/admin.py b/equipment/admin.py index de1ca76..97dd3ca 100644 --- a/equipment/admin.py +++ b/equipment/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse from simple_history.admin import SimpleHistoryAdmin from .models import ( Computer, @@ -7,49 +9,50 @@ from .models import ( DigitalCertificate, MaintenanceRecord, DecommissionRecord, + HandoverProtocol, ) - +from .models import DecommissionRecord # --- Inlines for Computer --- -class MaintenanceInlineComputer(admin.TabularInline): - model = MaintenanceRecord - fk_name = "computer" - extra = 0 +# class MaintenanceInlineComputer(admin.TabularInline): +# model = MaintenanceRecord +# fk_name = "computer" +# extra = 0 -class DecommissionInlineComputer(admin.StackedInline): - model = DecommissionRecord - fk_name = "computer" - extra = 0 - max_num = 1 +# class DecommissionInlineComputer(admin.StackedInline): +# model = DecommissionRecord +# fk_name = "computer" +# extra = 0 +# max_num = 1 -# --- Inlines for Monitor --- -class MaintenanceInlineMonitor(admin.TabularInline): - model = MaintenanceRecord - fk_name = "monitor" - extra = 0 +# # --- Inlines for Monitor --- +# class MaintenanceInlineMonitor(admin.TabularInline): +# model = MaintenanceRecord +# fk_name = "monitor" +# extra = 0 -class DecommissionInlineMonitor(admin.StackedInline): - model = DecommissionRecord - fk_name = "monitor" - extra = 0 - max_num = 1 +# class DecommissionInlineMonitor(admin.StackedInline): +# model = DecommissionRecord +# fk_name = "monitor" +# extra = 0 +# max_num = 1 -# --- Inlines for Peripheral --- -class MaintenanceInlinePeripheral(admin.TabularInline): - model = MaintenanceRecord - fk_name = "peripheral" - extra = 0 +# # --- Inlines for Peripheral --- +# class MaintenanceInlinePeripheral(admin.TabularInline): +# model = MaintenanceRecord +# fk_name = "peripheral" +# extra = 0 -class DecommissionInlinePeripheral(admin.StackedInline): - model = DecommissionRecord - fk_name = "peripheral" - extra = 0 - max_num = 1 +# class DecommissionInlinePeripheral(admin.StackedInline): +# model = DecommissionRecord +# fk_name = "peripheral" +# extra = 0 +# max_num = 1 @admin.register(Computer) @@ -57,7 +60,7 @@ class ComputerAdmin(SimpleHistoryAdmin): list_display = ("equipment_code", "oem_name", "status", "assigned_to", "entry_date") list_filter = ("status", "entry_date", "assigned_to") search_fields = ("equipment_code", "oem_name", "mac_address", "observations") - inlines = [MaintenanceInlineComputer, DecommissionInlineComputer] + # inlines = [MaintenanceInlineComputer, DecommissionInlineComputer] @admin.register(Monitor) @@ -65,7 +68,7 @@ class MonitorAdmin(SimpleHistoryAdmin): list_display = ("equipment_code", "name", "status", "assigned_to", "entry_date") list_filter = ("status", "entry_date", "assigned_to") search_fields = ("equipment_code", "name", "serial_number") - inlines = [MaintenanceInlineMonitor, DecommissionInlineMonitor] + # inlines = [MaintenanceInlineMonitor, DecommissionInlineMonitor] @admin.register(Peripheral) @@ -79,7 +82,7 @@ class PeripheralAdmin(SimpleHistoryAdmin): ) list_filter = ("status", "peripheral_type", "entry_date", "assigned_to") search_fields = ("equipment_code", "name", "serial_number") - inlines = [MaintenanceInlinePeripheral, DecommissionInlinePeripheral] + # inlines = [MaintenanceInlinePeripheral, DecommissionInlinePeripheral] @admin.register(DigitalCertificate) @@ -108,3 +111,46 @@ class DecommissionRecordAdmin(SimpleHistoryAdmin): list_display = ("document_number", "date", "reason") list_filter = ("date",) search_fields = ("document_number", "reason") + + +@admin.register(HandoverProtocol) +class HandoverProtocolAdmin(SimpleHistoryAdmin): + list_display = ( + "document_number", + "date", + "previous_holder", + "new_holder", + "it_validator", + "print_protocol_button", + ) + list_filter = ("date", "it_validator") + filter_horizontal = ("computers", "monitors", "peripherals") + # Make these fields read-only so they cannot be manually overwritten + readonly_fields = ("document_number", "it_validator") + + def save_model(self, request, obj, form, change): + if not obj.it_validator: + # Captures the logged-in user's name automatically + obj.it_validator = request.user.get_full_name() or request.user.username + super().save_model(request, obj, form, change) + + def print_protocol_button(self, obj): + if obj.pk: + url = reverse("equipment:print_handover", args=[obj.pk]) + return format_html( + '🖨️ Printează PV', + url, + ) + return "-" + + print_protocol_button.short_description = "Acțiune" + + # @admin.register(DecommissionRecord) + # class DecommissionRecordAdmin(SimpleHistoryAdmin): + # list_display = ("document_number", "date", "reason") + # search_fields = ("document_number", "reason") + # list_filter = ("date",) + + # Acest filter_horizontal va crea o interfață cu două coloane foarte utilă + # pentru a selecta ușor multiple echipamente din stânga în dreapta + filter_horizontal = ("computers", "monitors", "peripherals") diff --git a/equipment/forms.py b/equipment/forms.py new file mode 100644 index 0000000..b13985c --- /dev/null +++ b/equipment/forms.py @@ -0,0 +1,292 @@ +from django import forms +from django.urls import reverse_lazy +from .models import ( + Computer, + Monitor, + Peripheral, + MaintenanceRecord, + HandoverProtocol, + DecommissionRecord, + DigitalCertificate, +) + + +class ComputerForm(forms.ModelForm): + entry_date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + + class Meta: + model = Computer + fields = [ + "equipment_code", + "status", + "assigned_to", + "entry_date", + "oem_name", + "motherboard", + "cpu", + "ram", + "storage", + "optical_drive", + "gpu", + "mac_address", + "os_license", + "additional_licenses", + "observations", + ] + widgets = { + "equipment_code": forms.TextInput(attrs={"class": "form-control"}), + "status": forms.Select(attrs={"class": "form-select"}), + "assigned_to": forms.Select(attrs={"class": "form-select"}), + "oem_name": forms.TextInput(attrs={"class": "form-control"}), + "motherboard": forms.TextInput(attrs={"class": "form-control"}), + "cpu": forms.TextInput(attrs={"class": "form-control"}), + "ram": forms.TextInput(attrs={"class": "form-control"}), + "storage": forms.TextInput(attrs={"class": "form-control"}), + "optical_drive": forms.TextInput(attrs={"class": "form-control"}), + "gpu": forms.TextInput(attrs={"class": "form-control"}), + "mac_address": forms.TextInput(attrs={"class": "form-control"}), + "os_license": forms.TextInput(attrs={"class": "form-control"}), + "additional_licenses": forms.Textarea( + attrs={"class": "form-control", "rows": 2} + ), + "observations": forms.Textarea(attrs={"class": "form-control", "rows": 2}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields["assigned_to"].disabled = True + self.fields["assigned_to"].help_text = ( + "Transferul se face doar generând un Proces Verbal nou." + ) + + +class MonitorForm(forms.ModelForm): + entry_date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + + class Meta: + model = Monitor + fields = [ + "equipment_code", + "status", + "assigned_to", + "entry_date", + "name", + "serial_number", + "screen_diagonal", + "observations", + ] + widgets = { + "equipment_code": forms.TextInput(attrs={"class": "form-control"}), + "status": forms.Select(attrs={"class": "form-select"}), + "assigned_to": forms.Select(attrs={"class": "form-select"}), + "name": forms.TextInput(attrs={"class": "form-control"}), + "serial_number": forms.TextInput(attrs={"class": "form-control"}), + "screen_diagonal": forms.TextInput(attrs={"class": "form-control"}), + "observations": forms.Textarea(attrs={"class": "form-control", "rows": 2}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields["assigned_to"].disabled = True + self.fields["assigned_to"].help_text = ( + "Transferul se face doar generând un Proces Verbal nou." + ) + + +class PeripheralForm(forms.ModelForm): + entry_date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + + class Meta: + model = Peripheral + fields = [ + "equipment_code", + "status", + "assigned_to", + "entry_date", + "peripheral_type", + "name", + "serial_number", + "observations", + ] + widgets = { + "equipment_code": forms.TextInput(attrs={"class": "form-control"}), + "status": forms.Select(attrs={"class": "form-select"}), + "assigned_to": forms.Select(attrs={"class": "form-select"}), + "peripheral_type": forms.Select(attrs={"class": "form-select"}), + "name": forms.TextInput(attrs={"class": "form-control"}), + "serial_number": forms.TextInput(attrs={"class": "form-control"}), + "observations": forms.Textarea(attrs={"class": "form-control", "rows": 2}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields["assigned_to"].disabled = True + self.fields["assigned_to"].help_text = ( + "Transferul se face doar generând un Proces Verbal nou." + ) + + +class MaintenanceRecordForm(forms.ModelForm): + date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + + class Meta: + model = MaintenanceRecord + fields = ["date", "operation_type", "description", "performed_by"] + widgets = { + "operation_type": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "ex: Curățare praf, Upgrade RAM...", + } + ), + "description": forms.Textarea(attrs={"class": "form-control", "rows": 3}), + "performed_by": forms.TextInput(attrs={"class": "form-control"}), + } + + +class HandoverProtocolForm(forms.ModelForm): + date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + + class Meta: + model = HandoverProtocol + fields = [ + "date", + "previous_holder", + "new_holder", + "computers", + "monitors", + "peripherals", + "notes", + "scanned_document", + ] + widgets = { + "previous_holder": forms.Select( + attrs={ + "class": "form-select", + "hx-get": reverse_lazy("equipment:load_user_equipment"), + "hx-target": "#equipment-lists", + "hx-trigger": "change", + } + ), + "new_holder": forms.Select(attrs={"class": "form-select"}), + "computers": forms.SelectMultiple( + attrs={"class": "form-select", "size": 5} + ), + "monitors": forms.SelectMultiple(attrs={"class": "form-select", "size": 5}), + "peripherals": forms.SelectMultiple( + attrs={"class": "form-select", "size": 5} + ), + "notes": forms.Textarea(attrs={"class": "form-control", "rows": 2}), + "scanned_document": forms.FileInput(attrs={"class": "form-control"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.is_bound and not self.instance.pk: + self.fields["computers"].queryset = Computer.objects.none() + self.fields["monitors"].queryset = Monitor.objects.none() + self.fields["peripherals"].queryset = Peripheral.objects.none() + + +class DecommissionRecordForm(forms.ModelForm): + date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + + class Meta: + model = DecommissionRecord + fields = [ + "document_number", + "date", + "committee_members", + "reason", + "computers", + "monitors", + "peripherals", + ] + widgets = { + "document_number": forms.TextInput(attrs={"class": "form-control"}), + "committee_members": forms.Textarea( + attrs={"class": "form-control", "rows": 2} + ), + "reason": forms.Textarea(attrs={"class": "form-control", "rows": 2}), + "computers": forms.SelectMultiple( + attrs={"class": "form-select", "size": 5} + ), + "monitors": forms.SelectMultiple(attrs={"class": "form-select", "size": 5}), + "peripherals": forms.SelectMultiple( + attrs={"class": "form-select", "size": 5} + ), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.is_bound and not self.instance.pk: + self.fields["computers"].queryset = Computer.objects.filter(status="activ") + self.fields["monitors"].queryset = Monitor.objects.filter(status="activ") + self.fields["peripherals"].queryset = Peripheral.objects.filter( + status="activ" + ) + + +class DigitalCertificateForm(forms.ModelForm): + issue_date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + expiration_date = forms.DateField( + input_formats=["%d.%m.%Y", "%Y-%m-%d"], + widget=forms.TextInput( + attrs={"class": "form-control flatpickr-date", "placeholder": "ZZ.LL.AAAA"} + ), + ) + + class Meta: + model = DigitalCertificate + fields = [ + "certificate_type", + "assigned_to", + "issue_date", + "expiration_date", + "observations", + "is_canceled", + ] + widgets = { + "certificate_type": forms.Select(attrs={"class": "form-select"}), + "assigned_to": forms.Select(attrs={"class": "form-select"}), + "observations": forms.Textarea(attrs={"class": "form-control", "rows": 2}), + "is_canceled": forms.CheckboxInput(attrs={"class": "form-check-input"}), + } diff --git a/equipment/migrations/0004_handoverprotocol_historicalhandoverprotocol.py b/equipment/migrations/0004_handoverprotocol_historicalhandoverprotocol.py new file mode 100644 index 0000000..73bbbfe --- /dev/null +++ b/equipment/migrations/0004_handoverprotocol_historicalhandoverprotocol.py @@ -0,0 +1,62 @@ +# Generated by Django 6.1 on 2026-08-20 09:20 + +import datetime +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipment', '0003_computer_status_historicalcomputer_status_and_more'), + ('users', '0002_historicalcompartiment_historicaldirectie_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HandoverProtocol', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(default=datetime.date.today, verbose_name='Data documentului')), + ('document_number', models.CharField(max_length=100, unique=True, verbose_name='Număr Proces Verbal')), + ('it_validator', models.CharField(default='Compartimentul IT - Primăria Municipiului Roman', max_length=255, verbose_name='Validat de (IT)')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Observații')), + ('computers', models.ManyToManyField(blank=True, to='equipment.computer', verbose_name='Computere transferate')), + ('monitors', models.ManyToManyField(blank=True, to='equipment.monitor', verbose_name='Monitoare transferate')), + ('new_holder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_received', to='users.employee', verbose_name='Primit de')), + ('peripherals', models.ManyToManyField(blank=True, to='equipment.peripheral', verbose_name='Periferice transferate')), + ('previous_holder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_handed_over', to='users.employee', verbose_name='Predat de')), + ], + options={ + 'verbose_name': 'Proces Verbal Predare-Primire', + 'verbose_name_plural': 'Procese Verbale Predare-Primire', + }, + ), + migrations.CreateModel( + name='HistoricalHandoverProtocol', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('date', models.DateField(default=datetime.date.today, verbose_name='Data documentului')), + ('document_number', models.CharField(db_index=True, max_length=100, verbose_name='Număr Proces Verbal')), + ('it_validator', models.CharField(default='Compartimentul IT - Primăria Municipiului Roman', max_length=255, verbose_name='Validat de (IT)')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Observații')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('new_holder', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='users.employee', verbose_name='Primit de')), + ('previous_holder', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='users.employee', verbose_name='Predat de')), + ], + options={ + 'verbose_name': 'historical Proces Verbal Predare-Primire', + 'verbose_name_plural': 'historical Procese Verbale Predare-Primire', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/equipment/migrations/0005_handoverprotocol_scanned_document_and_more.py b/equipment/migrations/0005_handoverprotocol_scanned_document_and_more.py new file mode 100644 index 0000000..5329f36 --- /dev/null +++ b/equipment/migrations/0005_handoverprotocol_scanned_document_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 6.1 on 2026-08-24 09:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipment', '0004_handoverprotocol_historicalhandoverprotocol'), + ('users', '0002_historicalcompartiment_historicaldirectie_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='handoverprotocol', + name='scanned_document', + field=models.FileField(blank=True, null=True, upload_to='handover_protocols/', verbose_name='Document scanat (semnat)'), + ), + migrations.AddField( + model_name='historicalhandoverprotocol', + name='scanned_document', + field=models.TextField(blank=True, max_length=100, null=True, verbose_name='Document scanat (semnat)'), + ), + migrations.AlterField( + model_name='handoverprotocol', + name='document_number', + field=models.IntegerField(blank=True, unique=True, verbose_name='Număr Proces Verbal'), + ), + migrations.AlterField( + model_name='handoverprotocol', + name='it_validator', + field=models.CharField(blank=True, max_length=255, verbose_name='Întocmit de (IT)'), + ), + migrations.AlterField( + model_name='handoverprotocol', + name='new_holder', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_received', to='users.employee', verbose_name='Primit de (Primitor)'), + ), + migrations.AlterField( + model_name='handoverprotocol', + name='previous_holder', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_handed_over', to='users.employee', verbose_name='Predat de (Predător)'), + ), + migrations.AlterField( + model_name='historicalhandoverprotocol', + name='document_number', + field=models.IntegerField(blank=True, db_index=True, verbose_name='Număr Proces Verbal'), + ), + migrations.AlterField( + model_name='historicalhandoverprotocol', + name='it_validator', + field=models.CharField(blank=True, max_length=255, verbose_name='Întocmit de (IT)'), + ), + migrations.AlterField( + model_name='historicalhandoverprotocol', + name='new_holder', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='users.employee', verbose_name='Primit de (Primitor)'), + ), + migrations.AlterField( + model_name='historicalhandoverprotocol', + name='previous_holder', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='users.employee', verbose_name='Predat de (Predător)'), + ), + ] diff --git a/equipment/migrations/0006_historicalmaintenancerecord_next_maintenance_date_and_more.py b/equipment/migrations/0006_historicalmaintenancerecord_next_maintenance_date_and_more.py new file mode 100644 index 0000000..7053e84 --- /dev/null +++ b/equipment/migrations/0006_historicalmaintenancerecord_next_maintenance_date_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.1 on 2026-08-26 11:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipment', '0005_handoverprotocol_scanned_document_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmaintenancerecord', + name='next_maintenance_date', + field=models.DateField(blank=True, null=True, verbose_name='Următoarea mentenanță planificată'), + ), + migrations.AddField( + model_name='historicalperipheral', + name='observations', + field=models.TextField(blank=True, null=True, verbose_name='Observații'), + ), + migrations.AddField( + model_name='maintenancerecord', + name='next_maintenance_date', + field=models.DateField(blank=True, null=True, verbose_name='Următoarea mentenanță planificată'), + ), + migrations.AddField( + model_name='peripheral', + name='observations', + field=models.TextField(blank=True, null=True, verbose_name='Observații'), + ), + ] diff --git a/equipment/migrations/0007_alter_handoverprotocol_scanned_document_and_more.py b/equipment/migrations/0007_alter_handoverprotocol_scanned_document_and_more.py new file mode 100644 index 0000000..da134f5 --- /dev/null +++ b/equipment/migrations/0007_alter_handoverprotocol_scanned_document_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 6.1 on 2026-08-27 05:46 + +import equipment.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipment', '0006_historicalmaintenancerecord_next_maintenance_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='handoverprotocol', + name='scanned_document', + field=models.FileField(blank=True, null=True, upload_to='handover_protocols/', validators=[equipment.models.validate_file_size], verbose_name='Document scanat (semnat)'), + ), + migrations.AlterField( + model_name='historicalhandoverprotocol', + name='scanned_document', + field=models.TextField(blank=True, max_length=100, null=True, validators=[equipment.models.validate_file_size], verbose_name='Document scanat (semnat)'), + ), + ] diff --git a/equipment/migrations/0008_alter_decommissionrecord_options_and_more.py b/equipment/migrations/0008_alter_decommissionrecord_options_and_more.py new file mode 100644 index 0000000..2453f6f --- /dev/null +++ b/equipment/migrations/0008_alter_decommissionrecord_options_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 6.1 on 2026-08-28 06:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipment', '0007_alter_handoverprotocol_scanned_document_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='decommissionrecord', + options={'verbose_name': 'Proces Verbal de Casare', 'verbose_name_plural': 'Procese Verbale de Casare'}, + ), + migrations.AlterModelOptions( + name='historicaldecommissionrecord', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Proces Verbal de Casare', 'verbose_name_plural': 'historical Procese Verbale de Casare'}, + ), + migrations.RemoveField( + model_name='decommissionrecord', + name='computer', + ), + migrations.RemoveField( + model_name='decommissionrecord', + name='monitor', + ), + migrations.RemoveField( + model_name='decommissionrecord', + name='peripheral', + ), + migrations.RemoveField( + model_name='historicaldecommissionrecord', + name='computer', + ), + migrations.RemoveField( + model_name='historicaldecommissionrecord', + name='monitor', + ), + migrations.RemoveField( + model_name='historicaldecommissionrecord', + name='peripheral', + ), + migrations.AddField( + model_name='decommissionrecord', + name='committee_members', + field=models.TextField(blank=True, null=True, verbose_name='Membrii comisiei de casare (nume și funcție)'), + ), + migrations.AddField( + model_name='decommissionrecord', + name='computers', + field=models.ManyToManyField(blank=True, to='equipment.computer', verbose_name='Computere casate'), + ), + migrations.AddField( + model_name='decommissionrecord', + name='monitors', + field=models.ManyToManyField(blank=True, to='equipment.monitor', verbose_name='Monitoare casate'), + ), + migrations.AddField( + model_name='decommissionrecord', + name='peripherals', + field=models.ManyToManyField(blank=True, to='equipment.peripheral', verbose_name='Periferice casate'), + ), + migrations.AddField( + model_name='historicaldecommissionrecord', + name='committee_members', + field=models.TextField(blank=True, null=True, verbose_name='Membrii comisiei de casare (nume și funcție)'), + ), + migrations.AlterField( + model_name='decommissionrecord', + name='document_number', + field=models.CharField(max_length=100, verbose_name='Număr Proces Verbal'), + ), + migrations.AlterField( + model_name='historicaldecommissionrecord', + name='document_number', + field=models.CharField(max_length=100, verbose_name='Număr Proces Verbal'), + ), + ] diff --git a/equipment/migrations/0009_digitalcertificate_observations_and_more.py b/equipment/migrations/0009_digitalcertificate_observations_and_more.py new file mode 100644 index 0000000..e8276ea --- /dev/null +++ b/equipment/migrations/0009_digitalcertificate_observations_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.1 on 2026-08-28 09:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipment', '0008_alter_decommissionrecord_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='digitalcertificate', + name='observations', + field=models.TextField(blank=True, null=True, verbose_name='Observații'), + ), + migrations.AddField( + model_name='historicaldigitalcertificate', + name='observations', + field=models.TextField(blank=True, null=True, verbose_name='Observații'), + ), + ] diff --git a/equipment/models.py b/equipment/models.py index 65cc6e5..31e1f9e 100644 --- a/equipment/models.py +++ b/equipment/models.py @@ -3,12 +3,21 @@ from datetime import date from simple_history.models import HistoricalRecords from users.models import Employee from django.core.exceptions import ValidationError +from django.db.models import Max EQUIPMENT_STATUS = [ ("activ", "Activ"), ("casat", "Casat (Decommissioned)"), ] +from django.core.exceptions import ValidationError + + +def validate_file_size(value): + limit_mb = 3 + if value.size > limit_mb * 1024 * 1024: + raise ValidationError(f"Dimensiunea maximă permisă este de {limit_mb} MB.") + class Computer(models.Model): # Unique ID and Entry @@ -56,7 +65,8 @@ class Computer(models.Model): history = HistoricalRecords() def __str__(self): - return f"{self.equipment_code} - {self.oem_name or 'Sistem Asamblat'}" + # Format: Cod Echipament | CPU | RAM | Placă de bază + return f"{self.equipment_code} | {self.cpu} | {self.ram} | {self.motherboard}" class Meta: verbose_name = "Computer / Laptop" @@ -89,7 +99,8 @@ class Monitor(models.Model): history = HistoricalRecords() def __str__(self): - return f"{self.equipment_code} - {self.name}" + # Format: Cod Echipament | Model + return f"{self.equipment_code} | {self.name}" class Meta: verbose_name = "Monitor" @@ -119,6 +130,7 @@ class Peripheral(models.Model): ) name = models.CharField(max_length=255, verbose_name="Nume Echipament") serial_number = models.CharField(max_length=100, verbose_name="Număr Serial") + observations = models.TextField(blank=True, null=True, verbose_name="Observații") assigned_to = models.ForeignKey( Employee, @@ -135,7 +147,8 @@ class Peripheral(models.Model): history = HistoricalRecords() def __str__(self): - return f"{self.equipment_code} - {self.name} ({self.get_peripheral_type_display()})" + # Format: Cod Echipament | Tip | Model + return f"{self.equipment_code} | {self.get_peripheral_type_display()} | {self.name}" class Meta: verbose_name = "Periferic" @@ -153,6 +166,7 @@ class DigitalCertificate(models.Model): ) issue_date = models.DateField(verbose_name="Data emiterii") expiration_date = models.DateField(verbose_name="Data expirării") + observations = models.TextField(blank=True, null=True, verbose_name="Observații") is_canceled = models.BooleanField(default=False, verbose_name="Anulat?") # Links directly to the employee @@ -180,6 +194,9 @@ class MaintenanceRecord(models.Model): ) description = models.TextField(verbose_name="Descriere detaliată") performed_by = models.CharField(max_length=100, verbose_name="Efectuat de") + next_maintenance_date = models.DateField( + null=True, blank=True, verbose_name="Următoarea mentenanță planificată" + ) # Links to equipment (only one should be populated) computer = models.ForeignKey( @@ -230,62 +247,105 @@ class MaintenanceRecord(models.Model): class DecommissionRecord(models.Model): date = models.DateField(default=date.today, verbose_name="Data casării") document_number = models.CharField( - max_length=100, verbose_name="Număr document (Proces Verbal)" + max_length=100, verbose_name="Număr Proces Verbal" ) reason = models.TextField(verbose_name="Motivul casării (Defect, Uzat moral, etc.)") - # Links to equipment - computer = models.OneToOneField( - Computer, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="decommission_log", + # Am transformat relațiile în ManyToMany pentru a permite liste de echipamente + computers = models.ManyToManyField( + Computer, blank=True, verbose_name="Computere casate" ) - monitor = models.OneToOneField( - Monitor, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="decommission_log", + monitors = models.ManyToManyField( + Monitor, blank=True, verbose_name="Monitoare casate" ) - peripheral = models.OneToOneField( - Peripheral, - on_delete=models.CASCADE, - null=True, + peripherals = models.ManyToManyField( + Peripheral, blank=True, verbose_name="Periferice casate" + ) + + # Comisia de casare (opțional, dar util pentru PDF) + committee_members = models.TextField( blank=True, - related_name="decommission_log", + null=True, + verbose_name="Membrii comisiei de casare (nume și funcție)", ) history = HistoricalRecords() - def clean(self): - equipment_count = sum( - [bool(self.computer), bool(self.monitor), bool(self.peripheral)] - ) - if equipment_count != 1: - raise ValidationError( - "Un proces verbal de casare trebuie să fie legat de exact un singur echipament." - ) - - def save(self, *args, **kwargs): - self.clean() - super().save(*args, **kwargs) - - # Automatically update the status of the linked equipment to 'casat' - if self.computer: - self.computer.status = "casat" - self.computer.save() - elif self.monitor: - self.monitor.status = "casat" - self.monitor.save() - elif self.peripheral: - self.peripheral.status = "casat" - self.peripheral.save() + # Eliminăm vechea funcție clean() care forța un singur echipament + # Eliminăm vechea funcție save() - vom face actualizarea statusului direct din View (ca la predare) def __str__(self): - return f"Casare {self.document_number} din {self.date}" + return f"PV Casare nr. {self.document_number} din {self.date}" class Meta: - verbose_name = "Înregistrare Casare" - verbose_name_plural = "Înregistrări Casare" + verbose_name = "Proces Verbal de Casare" + verbose_name_plural = "Procese Verbale de Casare" + + +class HandoverProtocol(models.Model): + date = models.DateField(default=date.today, verbose_name="Data documentului") + document_number = models.IntegerField( + unique=True, blank=True, verbose_name="Număr Proces Verbal" + ) + + previous_holder = models.ForeignKey( + Employee, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="equipment_handed_over", + verbose_name="Predat de (Predător)", + ) + new_holder = models.ForeignKey( + Employee, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="equipment_received", + verbose_name="Primit de (Primitor)", + ) + + # We will populate this automatically in the admin + it_validator = models.CharField( + max_length=255, blank=True, verbose_name="Întocmit de (IT)" + ) + + computers = models.ManyToManyField( + Computer, blank=True, verbose_name="Computere transferate" + ) + monitors = models.ManyToManyField( + Monitor, blank=True, verbose_name="Monitoare transferate" + ) + peripherals = models.ManyToManyField( + Peripheral, blank=True, verbose_name="Periferice transferate" + ) + + notes = models.TextField(blank=True, null=True, verbose_name="Observații") + + # Field to attach the signed document + scanned_document = models.FileField( + upload_to="handover_protocols/", + blank=True, + null=True, + validators=[validate_file_size], + verbose_name="Document scanat (semnat)", + ) + + history = HistoricalRecords() + + def save(self, *args, **kwargs): + if not self.document_number: + max_num = HandoverProtocol.objects.aggregate(Max("document_number"))[ + "document_number__max" + ] + self.document_number = ( + (max_num + 1) if (max_num and max_num >= 1000) else 1000 + ) + super().save(*args, **kwargs) + + def __str__(self): + return f"PV Predare-Primire nr. {self.document_number} din {self.date}" + + class Meta: + verbose_name = "Proces Verbal Predare-Primire" + verbose_name_plural = "Procese Verbale Predare-Primire" diff --git a/equipment/templates/equipment/certificate_list.html b/equipment/templates/equipment/certificate_list.html new file mode 100644 index 0000000..8fbf649 --- /dev/null +++ b/equipment/templates/equipment/certificate_list.html @@ -0,0 +1,71 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Certificate Digitale (Semnături)

+

Gestiunea token-urilor și a semnăturilor în cloud.

+
+ {% if user.is_staff %} + + {% endif %} +
+ +
+ + + + + + + + + + + + + {% for cert in certificates %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
TitularTipDată EmitereDată ExpirareStatusAcțiuni
👤 {{ cert.assigned_to.name }} + {% if cert.certificate_type == 'usb' %}🔑 USB Token{% else %}☁️ Cloud{% endif %} + {{ cert.issue_date|date:"d.m.Y" }}{{ cert.expiration_date|date:"d.m.Y" }} + {{ cert.status_text }} + + {% if user.is_staff %} + +
Nu există certificate digitale înregistrate.
+
+ + + +{% endblock %} diff --git a/equipment/templates/equipment/dashboard.html b/equipment/templates/equipment/dashboard.html new file mode 100644 index 0000000..f8c1f65 --- /dev/null +++ b/equipment/templates/equipment/dashboard.html @@ -0,0 +1,104 @@ +{% extends 'base.html' %} + +{% block content %} + +
+

Panou de Control

+

Privire de ansamblu asupra infrastructurii I.T.

+
+ +
+ + +
+
+
+ ℹ️ Sumar & Echipamente Libere +
+ +
+

Total Calculatoare: {{ total_computers }}

+
+
Calculatoare Neasignate:
+
    + {% for pc in unassigned_computers %} +
  • 💻 {{ pc.equipment_code }} - {{ pc.cpu }} / {{ pc.ram }}
  • + {% empty %} +
  • Toate calculatoarele sunt asignate.
  • + {% endfor %} +
+ +
Monitoare Neasignate:
+
    + {% for mon in unassigned_monitors %} +
  • 🖥️ {{ mon.equipment_code }} - {{ mon.screen_diagonal }}
  • + {% empty %} +
  • Toate monitoarele sunt asignate.
  • + {% endfor %} +
+
+
+
+ + +
+
+
+ 🔄 Ultimele Acțiuni +
+ +
+
    + {% for record in activity_log %} +
  • + {{ record.history_date|date:"d.m.Y H:i" }} + {{ record.history_user.username|default:"Sistem" }} a + {% if record.history_type == '+' %} + adăugat + {% elif record.history_type == '~' %} + modificat + {% elif record.history_type == '-' %} + șters + {% endif %} +
    + {{ record.equipment_code }} +
  • + {% empty %} +
  • Nu există acțiuni recente.
  • + {% endfor %} +
+
+
+
+ + +
+
+
+ 🛠️ Ultimele Mentenanțe +
+ +
+
    + {% for maint in recent_maintenance %} +
  • + {{ maint.date|date:"d.m.Y" }} | Efectuat de: {{ maint.performed_by }} + {{ maint.operation_type }} +
    + + Pentru: + {% if maint.computer %}💻 {{ maint.computer.equipment_code }}{% endif %} + {% if maint.monitor %}🖥️ {{ maint.monitor.equipment_code }}{% endif %} + {% if maint.peripheral %}🖨️ {{ maint.peripheral.equipment_code }}{% endif %} + +
  • + {% empty %} +
  • Nu există mentenanțe înregistrate.
  • + {% endfor %} +
+
+
+
+ +
+{% endblock %} diff --git a/equipment/templates/equipment/decommission_list.html b/equipment/templates/equipment/decommission_list.html new file mode 100644 index 0000000..5cb187f --- /dev/null +++ b/equipment/templates/equipment/decommission_list.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Procese Verbale de Casare

+

Evidența echipamentelor scoase din uz.

+
+ {% if user.is_staff %} + + {% endif %} +
+ +
+ + + + + + + + + + + + {% for record in records %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
Număr PVDataMotivEchipamente CasateAcțiuni
# {{ record.document_number }}{{ record.date|date:"d.m.Y" }}{{ record.reason }} + + 💻 Computere: {{ record.computers.count }}
+ 🖥️ Monitoare: {{ record.monitors.count }}
+ 🖨️ Periferice: {{ record.peripherals.count }} +
+
+ + 📄 PDF + + {% if user.is_staff %} + + {% endif %} +
Nu există procese verbale de casare înregistrate.
+
+ + + +{% endblock %} diff --git a/equipment/templates/equipment/equipment_list.html b/equipment/templates/equipment/equipment_list.html new file mode 100644 index 0000000..66b2485 --- /dev/null +++ b/equipment/templates/equipment/equipment_list.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Inventar Echipamente Active

+ +
+ + + + + + + 📊 Exportă Inventar (Excel) + +
+
+ + +
+ {% include 'equipment/partials/equipment_tables.html' %} +
+ + + + + + +{% endblock %} diff --git a/equipment/templates/equipment/handover_list.html b/equipment/templates/equipment/handover_list.html new file mode 100644 index 0000000..5663f6a --- /dev/null +++ b/equipment/templates/equipment/handover_list.html @@ -0,0 +1,87 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Procese Verbale (Predare-Primire)

+ {% if user.is_staff %} + + {% endif %} +
+ +
+
+ + + + + + + + + + + + + {% for protocol in protocols %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
Nr. / DatăPredat dePrimit deEchipamente transferateDocument ScanatAcțiuni
Nr. {{ protocol.document_number }}
{{ protocol.date|date:"d.m.Y" }}
{{ protocol.previous_holder.name|default:"-" }}{{ protocol.new_holder.name|default:"-" }} + + {% for pc in protocol.computers.all %}💻 {{ pc.equipment_code }}
{% endfor %} + {% for mon in protocol.monitors.all %}🖥️ {{ mon.equipment_code }}
{% endfor %} + {% for peri in protocol.peripherals.all %}🖨️ {{ peri.equipment_code }}
{% endfor %} +
+
+ {% if protocol.scanned_document %} + 📥 Vezi PDF + {% else %} + Fără document + {% endif %} + + + + 📄 PDF + + + + +
Nu există procese verbale înregistrate.
+
+
+ + + +{% endblock %} diff --git a/equipment/templates/equipment/monitor_technical_sheet.html b/equipment/templates/equipment/monitor_technical_sheet.html new file mode 100644 index 0000000..bb01896 --- /dev/null +++ b/equipment/templates/equipment/monitor_technical_sheet.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} +{% block content %} +
+

Fișă Tehnică Monitor: {{ monitor.equipment_code }}

+ ⬅️ Înapoi la Inventar +
+ +
+
+
+
Specificații Curente
+
    +
  • Model: {{ monitor.name }}
  • +
  • Serial (S/N): {{ monitor.serial_number }}
  • +
  • Diagonală: {{ monitor.screen_diagonal }}
  • +
  • Utilizator Curent: {{ monitor.assigned_to.name|default:"Neasignat" }}
  • +
  • Observații: {{ monitor.observations|default:"-" }}
  • +
+
+
+
+
+
+ Jurnal Mentenanță + +
+
+ + + + {% for log in maintenance_logs %} + + {% empty %} + + {% endfor %} + +
DatăTip OperațiuneEfectuat de
{{ log.date|date:"d.m.Y" }}{{ log.operation_type }}
{{ log.description }}
{{ log.performed_by }}
Nu există intervenții de mentenanță.
+
+
+
+
Istoric Posesie (Custodie)
+ +
+ +{% endblock %} diff --git a/equipment/templates/equipment/partials/certificate_form.html b/equipment/templates/equipment/partials/certificate_form.html new file mode 100644 index 0000000..1de6d34 --- /dev/null +++ b/equipment/templates/equipment/partials/certificate_form.html @@ -0,0 +1,79 @@ + + +
+ {% csrf_token %} + + +
+ + + + + + + \ No newline at end of file diff --git a/equipment/templates/equipment/partials/computer_form.html b/equipment/templates/equipment/partials/computer_form.html new file mode 100644 index 0000000..b4b7db4 --- /dev/null +++ b/equipment/templates/equipment/partials/computer_form.html @@ -0,0 +1,86 @@ + +
+ {% csrf_token %} + + +
diff --git a/equipment/templates/equipment/partials/decommission_form.html b/equipment/templates/equipment/partials/decommission_form.html new file mode 100644 index 0000000..f8fb35b --- /dev/null +++ b/equipment/templates/equipment/partials/decommission_form.html @@ -0,0 +1,76 @@ + + +
+ + {% csrf_token %} + + + + +
+ + + diff --git a/equipment/templates/equipment/partials/equipment_dropdowns.html b/equipment/templates/equipment/partials/equipment_dropdowns.html new file mode 100644 index 0000000..8db3ae3 --- /dev/null +++ b/equipment/templates/equipment/partials/equipment_dropdowns.html @@ -0,0 +1,13 @@ +
+ + {{ form.computers }} +
Folosiți CTRL pt. selecție multiplă.
+
+
+ + {{ form.monitors }} +
+
+ + {{ form.peripherals }} +
diff --git a/equipment/templates/equipment/partials/equipment_tables.html b/equipment/templates/equipment/partials/equipment_tables.html new file mode 100644 index 0000000..2156203 --- /dev/null +++ b/equipment/templates/equipment/partials/equipment_tables.html @@ -0,0 +1,150 @@ + + +
+ + +
+ + + + + + + + + + + + {% for pc in computers %} + + + + + + + + {% empty %} + + {% endfor %} + +
CodDetalii SistemAsignat CătreData IntrăriiAcțiuni
{{ pc.equipment_code }} + + + {{ pc.cpu }} | {{ pc.ram }} + {{ pc.motherboard }} | {{ pc.mac_address }} + + {{ pc.assigned_to.name|default:"Neasignat" }}{{ pc.entry_date|date:"d.m.Y" }} + + + 📄 Fișă Tehnică + + + {% if user.is_staff %} + + {% endif %} +
Nu există computere active.
+
+ + +
+ + + + + + + + + + + + {% for monitor in monitors %} + + + + + + + + {% empty %} + + {% endfor %} + +
CodModel & S/NAsignat CătreData IntrăriiAcțiuni
{{ monitor.equipment_code }} + {{ monitor.name }} ({{ monitor.screen_diagonal }})
+ S/N: {{ monitor.serial_number }} +
{{ monitor.assigned_to.name|default:"Neasignat" }}{{ monitor.entry_date|date:"d.m.Y" }} + 📄 Fișă + {% if user.is_staff %} + + {% endif %} +
Nu există monitoare active.
+
+ + +
+ + + + + + + + + + + + {% for peripheral in peripherals %} + + + + + + + + {% empty %} + + {% endfor %} + +
CodTip & ModelAsignat CătreData IntrăriiAcțiuni
{{ peripheral.equipment_code }} + {{ peripheral.get_peripheral_type_display }}: {{ peripheral.name }}
+ S/N: {{ peripheral.serial_number }} +
{{ peripheral.assigned_to.name|default:"Neasignat" }}{{ peripheral.entry_date|date:"d.m.Y" }} + 📄 Fișă + {% if user.is_staff %} + + {% endif %} +
Nu există periferice active.
+
+
diff --git a/equipment/templates/equipment/partials/handover_form.html b/equipment/templates/equipment/partials/handover_form.html new file mode 100644 index 0000000..650ddc9 --- /dev/null +++ b/equipment/templates/equipment/partials/handover_form.html @@ -0,0 +1,78 @@ + + + +
+ + {% csrf_token %} + + + + +
+ + + + + + diff --git a/equipment/templates/equipment/partials/maintenance_form.html b/equipment/templates/equipment/partials/maintenance_form.html new file mode 100644 index 0000000..e00cb55 --- /dev/null +++ b/equipment/templates/equipment/partials/maintenance_form.html @@ -0,0 +1,31 @@ + +
+ {% csrf_token %} + + +
\ No newline at end of file diff --git a/equipment/templates/equipment/partials/monitor_form.html b/equipment/templates/equipment/partials/monitor_form.html new file mode 100644 index 0000000..9c57f4d --- /dev/null +++ b/equipment/templates/equipment/partials/monitor_form.html @@ -0,0 +1,36 @@ + +
+ {% csrf_token %} + + +
diff --git a/equipment/templates/equipment/partials/peripheral_form.html b/equipment/templates/equipment/partials/peripheral_form.html new file mode 100644 index 0000000..c0e161b --- /dev/null +++ b/equipment/templates/equipment/partials/peripheral_form.html @@ -0,0 +1,39 @@ + +
+ {% csrf_token %} + + +
diff --git a/equipment/templates/equipment/pdf_decommission.html b/equipment/templates/equipment/pdf_decommission.html new file mode 100644 index 0000000..fbeded8 --- /dev/null +++ b/equipment/templates/equipment/pdf_decommission.html @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + +
+ {% if logo_path %} + + {% endif %} + + U.A.T. Municipiul Roman
+ Compartiment administrare infrastructură I.T. și baze de date +
+ +
+ + +
+ PROCES VERBAL DE CASARE (SCOATERE DIN FUNCȚIUNE)
+ Nr. {{ record.document_number }} din {{ record.date|date:"d.m.Y" }} +
+ + +

Prezentul proces verbal s-a încheiat astăzi, {{ record.date|date:"d F Y"|lower }}, pentru scoaterea din funcțiune și casarea următoarelor echipamente de calcul și periferice:

+ + + +

Motivul casării: {{ record.reason }}

+ + + + + + + +
+ Membrii Comisiei de Casare:

+ {{ record.committee_members|linebreaksbr }}


+ Semnături:
+ ...............................................

+ ............................................... +
+ Întocmit de,

+ {{ generated_by }}


+ Semnătură:
+ ............................................... +
+ + + diff --git a/equipment/templates/equipment/pdf_handover.html b/equipment/templates/equipment/pdf_handover.html new file mode 100644 index 0000000..63dba45 --- /dev/null +++ b/equipment/templates/equipment/pdf_handover.html @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + +
+ {% if logo_path %} + + {% endif %} + + U.A.T. Municipiul Roman
+ Compartiment administrare infrastructură I.T. și baze de date +
+ +
+ + +
+ PROCES VERBAL DE PREDARE – PRIMIRE
+ Nr. {{ protocol.document_number }} +
+ + +

Încheiat astăzi: {{ protocol.date|date:"d F Y"|lower }}, între:

+ + +

+ {{ protocol.previous_holder.name|default:"Magazie (Neasignat)" }}, având funcția de + {{ protocol.previous_holder.job_title|default:"-" }} de la {{ protocol.previous_holder.department|default:"-" }}, + în calitate de PREDĂTOR +

+

și

+

+ {{ protocol.new_holder.name|default:"Magazie (Neasignat)" }}, având funcția de + {{ protocol.new_holder.job_title|default:"-" }} de la {{ protocol.new_holder.department|default:"-" }}, + în calitate de PRIMITOR +

+ +

Primitorul a preluat de la predător următoarele:

+ + + +

Observații: {{ protocol.notes|default:"Fără observații." }}

+ + + + + + + + +
+ Am predat,



+ ................................... +
+ Avizat I.T.,
+ {{ generated_by }}


+ ................................... +
+ Am primit,



+ ................................... +
+ + + diff --git a/equipment/templates/equipment/peripheral_technical_sheet.html b/equipment/templates/equipment/peripheral_technical_sheet.html new file mode 100644 index 0000000..5d59edc --- /dev/null +++ b/equipment/templates/equipment/peripheral_technical_sheet.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} +{% block content %} +
+

Fișă Tehnică Periferic: {{ peripheral.equipment_code }}

+ ⬅️ Înapoi la Inventar +
+ +
+
+
+
Specificații Curente
+
    +
  • Tip: {{ peripheral.get_peripheral_type_display }}
  • +
  • Model: {{ peripheral.name }}
  • +
  • Serial (S/N): {{ peripheral.serial_number }}
  • +
  • Utilizator Curent: {{ peripheral.assigned_to.name|default:"Neasignat" }}
  • +
  • Observații: {{ peripheral.observations|default:"-" }}
  • +
+
+
+
+
+
+ Jurnal Mentenanță + +
+
+ + + + {% for log in maintenance_logs %} + + {% empty %} + + {% endfor %} + +
DatăTip OperațiuneEfectuat de
{{ log.date|date:"d.m.Y" }}{{ log.operation_type }}
{{ log.description }}
{{ log.performed_by }}
Nu există intervenții de mentenanță.
+
+
+
+
Istoric Posesie (Custodie)
+ +
+ +{% endblock %} diff --git a/equipment/templates/equipment/print_handover.html b/equipment/templates/equipment/print_handover.html new file mode 100644 index 0000000..50b6234 --- /dev/null +++ b/equipment/templates/equipment/print_handover.html @@ -0,0 +1,76 @@ + + + + + PV Predare-Primire {{ protocol.document_number }} + + + + +
+ +
+ +

PROCES VERBAL DE PREDARE – PRIMIRE

+

Nr. {{ protocol.document_number }}

+ +

+ Încheiat astăzi: {{ protocol.date|date:"d F Y" }}, între:

+ + {{ protocol.previous_holder.name|default:"____________________" }}, având funcția + {{ protocol.previous_holder.role|default:"____________________" }} - + {% if protocol.previous_holder.compartiment %}{{ protocol.previous_holder.compartiment.name }} + {% elif protocol.previous_holder.serviciu %}{{ protocol.previous_holder.serviciu.name }} + {% elif protocol.previous_holder.directie %}{{ protocol.previous_holder.directie.name }}{% endif %}, + în calitate de PREDĂTOR și

+ + {{ protocol.new_holder.name|default:"____________________" }}, având funcția + {{ protocol.new_holder.role|default:"____________________" }} - + {% if protocol.new_holder.compartiment %}{{ protocol.new_holder.compartiment.name }} + {% elif protocol.new_holder.serviciu %}{{ protocol.new_holder.serviciu.name }} + {% elif protocol.new_holder.directie %}{{ protocol.new_holder.directie.name }}{% endif %}, + în calitate de PRIMITOR. +

+ +

Primitorul a preluat de la predător echipamentul de mai jos:

+ +
+ {% for pc in protocol.computers.all %} + Unitate centrală: nr. inventar {{ pc.equipment_code }} | Procesor: {{ pc.cpu }} | Placă de bază: {{ pc.motherboard }} | RAM: {{ pc.ram }} | Stocare: {{ pc.storage }} {% if pc.optical_drive %}| Optic: {{ pc.optical_drive }}{% endif %} | MAC {{ pc.mac_address }} | Licența S.O.: {{ pc.os_license }} {% if pc.additional_licenses %}| Licențe: {{ pc.additional_licenses }}{% endif %};
+ {% endfor %} + + {% for monitor in protocol.monitors.all %} + Monitor: nr. inventar {{ monitor.equipment_code }} | Denumire: {{ monitor.name }} | s/n: {{ monitor.serial_number }};
+ {% endfor %} + + {% for peripheral in protocol.peripherals.all %} + {{ peripheral.get_peripheral_type_display }}: nr. inventar {{ peripheral.equipment_code }} | Denumire: {{ peripheral.name }} | s/n: {{ peripheral.serial_number }};
+ {% endfor %} +
+ +
+
+ Întocmit,
+ Numele în clar: {{ protocol.it_validator }}
+ Data: {{ protocol.date|date:"d.m.Y" }}
+ Semnătura: .................................... +
+
+ Am predat,
+ Numele în clar: {{ protocol.previous_holder.name|default:"...................................." }}
+ Data: {{ protocol.date|date:"d.m.Y" }}
+ Semnătura: .................................... +
+
+ Am primit,
+ Numele în clar: {{ protocol.new_holder.name|default:"...................................." }}
+ Data: {{ protocol.date|date:"d.m.Y" }}
+ Semnătura: .................................... +
+
+ + diff --git a/equipment/templates/equipment/print_inventory.html b/equipment/templates/equipment/print_inventory.html new file mode 100644 index 0000000..74e7b30 --- /dev/null +++ b/equipment/templates/equipment/print_inventory.html @@ -0,0 +1,105 @@ +{% load static %} + + + + + Fișă Inventar - {{ employee.name }} + + + + +
+ +
+ Stema Roman +
+
U.A.T. Municipiul Roman
+
Compartiment administrare infrastructură I.T. și baze de date
+
+
+ +
+ +

Fișă de Inventar Echipamente IT

+
la data de: {% now "d.m.Y" %}
+ +
+ Nume Angajat: {{ employee.name }}
+ Funcție: {{ employee.role }}
+ Birou: {{ employee.office_number }}
+ Structură: + {% if employee.directie %}{{ employee.directie.name }}{% endif %} + {% if employee.serviciu %} / {{ employee.serviciu.name }}{% endif %} + {% if employee.compartiment %} / {{ employee.compartiment.name }}{% endif %} +
+ +

Echipamente în custodie

+ + + + + + + + + + {% for pc in inventory.computers %} + + + + + + {% endfor %} + + {% for monitor in inventory.monitors %} + + + + + + {% endfor %} + + {% for peripheral in inventory.peripherals %} + + + + + + {% endfor %} + +
Cod EchipamentTipDetalii / Producător
{{ pc.equipment_code }}Computer/Laptop + {{ pc.oem_name|default:"Sistem Asamblat" }}
+ + Procesor: {{ pc.cpu }} | Placă de bază: {{ pc.motherboard }} | RAM: {{ pc.ram }} | Stocare: {{ pc.storage }} {% if pc.optical_drive %}| Optic: {{ pc.optical_drive }}{% endif %}
+ MAC: {{ pc.mac_address }} | Licența S.O.: {{ pc.os_license }} {% if pc.additional_licenses %}| Licențe: {{ pc.additional_licenses }}{% endif %} +
+
{{ monitor.equipment_code }}Monitor + {{ monitor.name }}
+ + Diagonală: {{ monitor.screen_diagonal }} | S/N: {{ monitor.serial_number }} + {% if monitor.observations %}
Observații: {{ monitor.observations }}{% endif %} +
+
{{ peripheral.equipment_code }}{{ peripheral.get_peripheral_type_display }} + {{ peripheral.name }}
+ + S/N: {{ peripheral.serial_number }} + +
+ +
+
+ Compartimentul IT

+ .................................... +
+
+ Semnătură Titular (Angajat)

+ .................................... +
+
+ + diff --git a/equipment/templates/equipment/technical_sheet.html b/equipment/templates/equipment/technical_sheet.html new file mode 100644 index 0000000..fc3fe5b --- /dev/null +++ b/equipment/templates/equipment/technical_sheet.html @@ -0,0 +1,127 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Fișă Tehnică: {{ computer.equipment_code }}

+ ⬅️ Înapoi la Inventar +
+ +
+ +
+
+
Specificații Curente
+
    +
  • OEM: {{ computer.oem_name|default:"Sistem Asamblat" }}
  • +
  • Placă de bază: {{ computer.motherboard }}
  • +
  • CPU: {{ computer.cpu }}
  • +
  • RAM: {{ computer.ram }}
  • +
  • Stocare: {{ computer.storage }}
  • +
  • OS: {{ computer.os_license }}
  • +
  • MAC: {{ computer.mac_address }}
  • +
  • Utilizator Curent: {{ computer.assigned_to.name|default:"Neasignat" }}
  • +
+
+
+ + +
+ + +
+
Istoric Modificări Hardware
+
+ + + + + + + + + + + {% for change in hardware_changes %} + + + + + + + {% empty %} + + {% endfor %} + +
DatăComponentăValoare VecheValoare Nouă
{{ change.date|date:"d.m.Y H:i" }}{{ change.field }}{{ change.old|default:"-" }}{{ change.new|default:"-" }}
Nu există modificări hardware înregistrate.
+
+
+ + +
+
+ Jurnal Mentenanță + + {% if user.is_staff %} + + {% endif %} +
+
+ + + + + + + + + + {% for log in maintenance_logs %} + + + + + + {% empty %} + + {% endfor %} + +
DatăTip OperațiuneEfectuat de
{{ log.date|date:"d.m.Y" }}{{ log.operation_type }}
{{ log.description }}
{{ log.performed_by }}
Nu există intervenții de mentenanță.
+
+
+
+
Istoric Posesie (Custodie)
+ +
+ +{% endblock %} diff --git a/equipment/tests.py b/equipment/tests.py index 7ce503c..ed7172c 100644 --- a/equipment/tests.py +++ b/equipment/tests.py @@ -1,3 +1,143 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.urls import reverse +from users.models import Employee +from .models import Computer, Monitor, Peripheral, HandoverProtocol, DecommissionRecord +from django.contrib.auth.models import User -# Create your tests here. + +class EquipmentModelTests(TestCase): + def setUp(self): + self.emp = Employee.objects.create( + name="Angajat Test", + username="angajat.test", + role="Consilier", + office_number="10", + email="test@primariaroman.ro", + ) + self.pc = Computer.objects.create( + equipment_code="92601011", + motherboard="ASUS Test", + cpu="Intel i5", + ram="16GB", + storage="512GB SSD", + mac_address="00:11:22:33:44:55", + os_license="Windows 11 Pro", + assigned_to=self.emp, + status="activ", + ) + + def test_active_inventory_property(self): + # Verify the get_active_inventory property pulls the assigned PC + inventory = self.emp.get_active_inventory + self.assertIn(self.pc, inventory["computers"]) + self.assertEqual(len(inventory["computers"]), 1) + + def test_handover_protocol_auto_numbering(self): + # First protocol should be exactly 1000 + pv1 = HandoverProtocol.objects.create(previous_holder=self.emp) + self.assertEqual(pv1.document_number, 1000) + + # Second protocol should increment to 1001 + pv2 = HandoverProtocol.objects.create(previous_holder=self.emp) + self.assertEqual(pv2.document_number, 1001) + + +class EquipmentViewsTests(TestCase): + def setUp(self): + self.client = Client() + + # Creăm un utilizator simplu (non-staff) + self.normal_user = User.objects.create_user( + username="user.simplu", password="password123", is_staff=False + ) + + # Creăm un utilizator IT (staff) + self.staff_user = User.objects.create_user( + username="user.it", password="password123", is_staff=True + ) + + def test_dashboard_access_authenticated(self): + # Un utilizator logat trebuie să primească status 200 (OK) pe dashboard + self.client.login(username="user.simplu", password="password123") + response = self.client.get( + reverse("equipment:dashboard") + ) # Ajustează numele rutei dacă diferă + self.assertEqual(response.status_code, 200) + + def test_dashboard_access_unauthenticated(self): + # Un vizitator nelogat trebuie redirecționat (302) către pagina de login + response = self.client.get(reverse("equipment:dashboard")) + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_staff_permission_denied_for_normal_users(self): + # Un utilizator simplu nu are voie să acceseze formularul de adăugare certificat + self.client.login(username="user.simplu", password="password123") + response = self.client.get(reverse("equipment:certificate_create")) + self.assertEqual( + response.status_code, 403 + ) # 403 = Forbidden (PermissionDenied) + + def test_staff_permission_granted_for_it(self): + # Un utilizator IT trebuie să poată accesa formularul + self.client.login(username="user.it", password="password123") + response = self.client.get(reverse("equipment:certificate_create")) + self.assertEqual(response.status_code, 200) + + +class EquipmentBusinessLogicTests(TestCase): + def setUp(self): + self.client = Client() + self.staff_user = User.objects.create_user( + username="user.it", password="password123", is_staff=True + ) + self.client.login(username="user.it", password="password123") + + # Creăm un angajat + self.emp = Employee.objects.create( + name="Angajat Test", username="angajat.test", role="Inspector" + ) + + # Creăm un calculator activ, atribuit angajatului + self.pc = Computer.objects.create( + equipment_code="COMP-TEST-01", + cpu="Intel i5", + ram="16GB", + motherboard="ASUS", + mac_address="00:00:00:00:00:00", + status="activ", + assigned_to=self.emp, + ) + + def test_export_csv_headers(self): + # Verificăm dacă ruta de export returnează un fișier CSV valid + response = self.client.get(reverse("equipment:export_inventory")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv; charset=utf-8-sig") + self.assertIn( + 'attachment; filename="Inventar_Echipamente.csv"', + response["Content-Disposition"], + ) + + def test_decommission_logic_updates_status(self): + # Simulăm trimiterea formularului de casare (POST request) + response = self.client.post( + reverse("equipment:decommission_create"), + { + "document_number": "PV-999", + "date": "2026-08-31", + "reason": "Test de casare automată", + "computers": [self.pc.id], # Selectăm PC-ul creat în setUp + # Celelalte pot fi goale dacă nu sunt required + }, + ) + + # Status 204 sau 302 confirmă că formularul s-a procesat cu succes + self.assertIn(response.status_code, [204, 302]) + + # Reîncărcăm PC-ul din baza de date pentru a vedea modificările + self.pc.refresh_from_db() + + # Verificăm dacă logica noastră a eliberat echipamentul corect + self.assertEqual(self.pc.status, "casat") + self.assertIsNone(self.pc.assigned_to) diff --git a/equipment/urls.py b/equipment/urls.py new file mode 100644 index 0000000..644ddb1 --- /dev/null +++ b/equipment/urls.py @@ -0,0 +1,86 @@ +from django.urls import path +from . import views + +app_name = "equipment" + +urlpatterns = [ + # Print Routes + path( + "print/inventory//", + views.print_inventory_sheet, + name="print_inventory", + ), + path( + "print/handover//", + views.print_handover_protocol, + name="print_handover", + ), + # Dashboard + # path("", views.equipment_list, name="equipment_list"), + # Ruta principală devine Dashboard-ul + path("", views.dashboard_view, name="dashboard"), + path("inventory/", views.equipment_list, name="equipment_list"), + path("inventory/export/", views.export_inventory_csv, name="export_inventory"), + # Computer Routes + path("computer/new/", views.computer_form, name="computer_create"), + path("computer//edit/", views.computer_form, name="computer_edit"), + path( + "computer//sheet/", + views.computer_technical_sheet, + name="computer_sheet", + ), + path( + "computer//maintenance/new/", + views.computer_maintenance_create, + name="computer_maintenance_create", + ), + # Monitor Routes + path("monitor/new/", views.monitor_form, name="monitor_create"), + path("monitor//edit/", views.monitor_form, name="monitor_edit"), + path( + "monitor//sheet/", views.monitor_technical_sheet, name="monitor_sheet" + ), + path( + "monitor//maintenance/new/", + views.monitor_maintenance_create, + name="monitor_maintenance_create", + ), + # Peripheral Routes + path("peripheral/new/", views.peripheral_form, name="peripheral_create"), + path("peripheral//edit/", views.peripheral_form, name="peripheral_edit"), + path( + "peripheral//sheet/", + views.peripheral_technical_sheet, + name="peripheral_sheet", + ), + path( + "peripheral//maintenance/new/", + views.peripheral_maintenance_create, + name="peripheral_maintenance_create", + ), + # Rute Procese Verbale (Handover) + path("handover/", views.handover_protocol_list, name="handover_list"), + path("handover/new/", views.handover_protocol_form, name="handover_create"), + path("handover//edit/", views.handover_protocol_form, name="handover_edit"), + path("handover//pdf/", views.generate_handover_pdf, name="handover_pdf"), + path( + "handover/load-equipment/", + views.load_user_equipment, + name="load_user_equipment", + ), + path("decommission/", views.decommission_list, name="decommission_list"), + path("decommission/create/", views.decommission_form, name="decommission_create"), + path( + "decommission//edit/", views.decommission_form, name="decommission_edit" + ), + path( + "decommission//pdf/", + views.generate_decommission_pdf, + name="decommission_pdf", + ), + path("certificates/", views.certificate_list, name="certificate_list"), + path("certificates/new/", views.certificate_form, name="certificate_create"), + path( + "certificates//edit/", views.certificate_form, name="certificate_edit" + ), +] diff --git a/equipment/views.py b/equipment/views.py index 91ea44a..d003492 100644 --- a/equipment/views.py +++ b/equipment/views.py @@ -1,3 +1,916 @@ -from django.shortcuts import render +from django.contrib.staticfiles import finders +from django.template.loader import get_template +from xhtml2pdf import pisa +from django.contrib.auth.decorators import login_required +from django.urls import reverse +from django.http import HttpResponse +from django.db.models import Q +from django.shortcuts import render, get_object_or_404 +from users.models import Employee +from .models import Computer, Monitor, Peripheral, MaintenanceRecord, HandoverProtocol +from .forms import ( + ComputerForm, + MonitorForm, + PeripheralForm, + MaintenanceRecordForm, + HandoverProtocolForm, +) +from django.core.exceptions import PermissionDenied +from itertools import chain +from operator import attrgetter +from django.template.loader import get_template +from django.http import HttpResponse +from .forms import DecommissionRecordForm +from .models import DecommissionRecord +from datetime import date, timedelta +from .models import DigitalCertificate +from .forms import DigitalCertificateForm +import csv -# Create your views here. + +@login_required +def computer_maintenance_create(request, pk): + computer = get_object_or_404(Computer, pk=pk) + + if request.method == "POST": + # 1. Creăm instanța și îi legăm computerul DE LA ÎNCEPUT + maintenance_instance = MaintenanceRecord(computer=computer) + + # 2. Pasăm instanța către formular pentru a trece de validarea din clean() + form = MaintenanceRecordForm(request.POST, instance=maintenance_instance) + + if form.is_valid(): + form.save() # Acum se va salva perfect! + + # Returnăm status 204 și instruim HTMX să reîncarce complet pagina + response = HttpResponse(status=204) + response["HX-Refresh"] = "true" + return response + else: + initial_name = ( + request.user.get_full_name() or request.user.username + if request.user.is_authenticated + else "" + ) + form = MaintenanceRecordForm(initial={"performed_by": initial_name}) + + return render( + request, + "equipment/partials/maintenance_form.html", + { + "form": form, + "equipment_title": computer.equipment_code, + "post_url": reverse( + "equipment:computer_maintenance_create", args=[computer.pk] + ), + }, + ) + + +@login_required +def print_inventory_sheet(request, employee_id): + employee = get_object_or_404(Employee, id=employee_id) + inventory = employee.get_active_inventory + + context = { + "employee": employee, + "inventory": inventory, + } + return render(request, "equipment/print_inventory.html", context) + + +@login_required +def print_handover_protocol(request, protocol_id): + protocol = get_object_or_404(HandoverProtocol, id=protocol_id) + + context = { + "protocol": protocol, + } + return render(request, "equipment/print_handover.html", context) + + +@login_required +def equipment_list(request): + query = request.GET.get("search", "") + + # Fetch only active equipment + computers = Computer.objects.filter(status="activ").order_by("-entry_date") + monitors = Monitor.objects.filter(status="activ").order_by("-entry_date") + peripherals = Peripheral.objects.filter(status="activ").order_by("-entry_date") + + if query: + computers = computers.filter( + Q(equipment_code__icontains=query) + | Q(oem_name__icontains=query) + | Q(motherboard__icontains=query) + | Q(cpu__icontains=query) + | Q(ram__icontains=query) + | Q(storage__icontains=query) + | Q(gpu__icontains=query) + | Q(mac_address__icontains=query) + | Q(os_license__icontains=query) + | Q(observations__icontains=query) + | Q(assigned_to__name__icontains=query) + ) + monitors = monitors.filter( + Q(equipment_code__icontains=query) + | Q(name__icontains=query) + | Q(serial_number__icontains=query) + | Q(screen_diagonal__icontains=query) + | Q(observations__icontains=query) + | Q(assigned_to__name__icontains=query) + ) + peripherals = peripherals.filter( + Q(equipment_code__icontains=query) + | Q(name__icontains=query) + | Q(peripheral_type__icontains=query) + | Q(serial_number__icontains=query) + | Q(observations__icontains=query) + | Q(assigned_to__name__icontains=query) + ) + + context = { + "computers": computers, + "monitors": monitors, + "peripherals": peripherals, + } + + if request.headers.get("HX-Request"): + return render(request, "equipment/partials/equipment_tables.html", context) + + return render(request, "equipment/equipment_list.html", context) + + +@login_required +def computer_form(request, pk=None): + # Doar personalul IT (staff) are voie să adauge/modifice + if not request.user.is_staff: + raise PermissionDenied("Nu aveți permisiunea de a edita echipamente.") + + if pk: + computer = get_object_or_404(Computer, pk=pk) + else: + computer = None + + if request.method == "POST": + form = ComputerForm(request.POST, instance=computer) + if form.is_valid(): + form.save() + # Return 204 and trigger the table refresh and modal close + response = HttpResponse(status=204) + response["HX-Trigger"] = "equipmentListChanged, closeModal" + return response + else: + form = ComputerForm(instance=computer) + + return render( + request, + "equipment/partials/computer_form.html", + {"form": form, "computer": computer}, + ) + + +@login_required +def monitor_form(request, pk=None): + if pk: + monitor = get_object_or_404(Monitor, pk=pk) + else: + monitor = None + + if request.method == "POST": + form = MonitorForm(request.POST, instance=monitor) + if form.is_valid(): + form.save() + response = HttpResponse(status=204) + response["HX-Trigger"] = "equipmentListChanged, closeModal" + return response + else: + form = MonitorForm(instance=monitor) + + return render( + request, + "equipment/partials/monitor_form.html", + {"form": form, "monitor": monitor}, + ) + + +@login_required +def peripheral_form(request, pk=None): + if pk: + peripheral = get_object_or_404(Peripheral, pk=pk) + else: + peripheral = None + + if request.method == "POST": + form = PeripheralForm(request.POST, instance=peripheral) + if form.is_valid(): + form.save() + response = HttpResponse(status=204) + response["HX-Trigger"] = "equipmentListChanged, closeModal" + return response + else: + form = PeripheralForm(instance=peripheral) + + return render( + request, + "equipment/partials/peripheral_form.html", + {"form": form, "peripheral": peripheral}, + ) + + +@login_required +def computer_technical_sheet(request, pk): + computer = get_object_or_404(Computer, pk=pk) + + # Fetch maintenance logs (using the related_name from your models) + maintenance_logs = computer.maintenance_logs.all().order_by("-date") + + # Calculate hardware changes from simple_history + history = list(computer.history.all()) + hardware_changes = [] + + tracked_fields = { + "cpu": "Procesor", + "ram": "Memorie RAM", + "storage": "Stocare", + "gpu": "Placă Video", + "motherboard": "Placă de bază", + "status": "Status", + } + + # Compare each historical record with the one that came before it + for i in range(len(history) - 1): + new_rec = history[i] + old_rec = history[i + 1] + + for field, label in tracked_fields.items(): + old_val = getattr(old_rec, field) + new_val = getattr(new_rec, field) + + if old_val != new_val: + hardware_changes.append( + { + "date": new_rec.history_date, + "field": label, + "old": old_val, + "new": new_val, + } + ) + + # --- Extragere istoric posesie --- + ownership_history = [] + # Parcurgem cronologic (de la cea mai veche la cea mai nouă înregistrare) + history_asc = list(computer.history.all().order_by("history_date")) + + current_owner = None + start_date = None + + for record in history_asc: + owner = record.assigned_to + + # Dacă s-a schimbat posesorul + if owner != current_owner: + # Salvăm perioada precedentă + if start_date is not None: + ownership_history.append( + { + "owner": ( + current_owner.name + if current_owner + else "Magazie (Neasignat)" + ), + "start_date": start_date, + "end_date": record.history_date, + } + ) + + # Începem perioada nouă + current_owner = owner + start_date = record.history_date + + # Adăugăm perioada curentă, care e încă în desfășurare + if start_date is not None: + ownership_history.append( + { + "owner": current_owner.name if current_owner else "Magazie (Neasignat)", + "start_date": start_date, + "end_date": None, # Semnifică 'Prezent' + } + ) + + # Inversăm lista pentru a avea cel mai recent posesor sus + ownership_history.reverse() + # ------------------------------------------ + + return render( + request, + "equipment/technical_sheet.html", + { + "computer": computer, + "maintenance_logs": maintenance_logs, + "hardware_changes": hardware_changes, + "ownership_history": ownership_history, + }, + ) + + +@login_required +def monitor_technical_sheet(request, pk): + monitor = get_object_or_404(Monitor, pk=pk) + maintenance_logs = monitor.maintenance_logs.all().order_by("-date") + + # --- Extragere istoric posesie --- + ownership_history = [] + history_asc = list(monitor.history.all().order_by("history_date")) + current_owner = None + start_date = None + + for record in history_asc: + owner = record.assigned_to + if owner != current_owner: + if start_date is not None: + ownership_history.append( + { + "owner": ( + current_owner.name + if current_owner + else "Magazie (Neasignat)" + ), + "start_date": start_date, + "end_date": record.history_date, + } + ) + current_owner = owner + start_date = record.history_date + + if start_date is not None: + ownership_history.append( + { + "owner": current_owner.name if current_owner else "Magazie (Neasignat)", + "start_date": start_date, + "end_date": None, + } + ) + ownership_history.reverse() + # --------------------------------- + + return render( + request, + "equipment/monitor_technical_sheet.html", + { + "monitor": monitor, + "maintenance_logs": maintenance_logs, + "ownership_history": ownership_history, # Trimitem variabila către HTML + }, + ) + + +@login_required +def monitor_maintenance_create(request, pk): + monitor = get_object_or_404(Monitor, pk=pk) + if request.method == "POST": + maintenance_instance = MaintenanceRecord(monitor=monitor) + form = MaintenanceRecordForm(request.POST, instance=maintenance_instance) + if form.is_valid(): + form.save() + response = HttpResponse(status=204) + response["HX-Refresh"] = "true" + return response + else: + initial_name = ( + request.user.get_full_name() or request.user.username + if request.user.is_authenticated + else "" + ) + form = MaintenanceRecordForm(initial={"performed_by": initial_name}) + + return render( + request, + "equipment/partials/maintenance_form.html", + { + "form": form, + "equipment_title": monitor.equipment_code, + "post_url": reverse( + "equipment:monitor_maintenance_create", args=[monitor.pk] + ), + }, + ) + + +@login_required +@login_required +def peripheral_technical_sheet(request, pk): + peripheral = get_object_or_404(Peripheral, pk=pk) + maintenance_logs = peripheral.maintenance_logs.all().order_by("-date") + + # --- Extragere istoric posesie --- + ownership_history = [] + history_asc = list(peripheral.history.all().order_by("history_date")) + current_owner = None + start_date = None + + for record in history_asc: + owner = record.assigned_to + if owner != current_owner: + if start_date is not None: + ownership_history.append( + { + "owner": ( + current_owner.name + if current_owner + else "Magazie (Neasignat)" + ), + "start_date": start_date, + "end_date": record.history_date, + } + ) + current_owner = owner + start_date = record.history_date + + if start_date is not None: + ownership_history.append( + { + "owner": current_owner.name if current_owner else "Magazie (Neasignat)", + "start_date": start_date, + "end_date": None, + } + ) + ownership_history.reverse() + # --------------------------------- + + return render( + request, + "equipment/peripheral_technical_sheet.html", + { + "peripheral": peripheral, + "maintenance_logs": maintenance_logs, + "ownership_history": ownership_history, # Trimitem variabila către HTML + }, + ) + + +@login_required +def peripheral_maintenance_create(request, pk): + peripheral = get_object_or_404(Peripheral, pk=pk) + if request.method == "POST": + maintenance_instance = MaintenanceRecord(peripheral=peripheral) + form = MaintenanceRecordForm(request.POST, instance=maintenance_instance) + if form.is_valid(): + form.save() + response = HttpResponse(status=204) + response["HX-Refresh"] = "true" + return response + else: + initial_name = ( + request.user.get_full_name() or request.user.username + if request.user.is_authenticated + else "" + ) + form = MaintenanceRecordForm(initial={"performed_by": initial_name}) + + return render( + request, + "equipment/partials/maintenance_form.html", + { + "form": form, + "equipment_title": peripheral.equipment_code, + "post_url": reverse( + "equipment:peripheral_maintenance_create", args=[peripheral.pk] + ), + }, + ) + + +@login_required +def handover_protocol_list(request): + # Preluăm toate procesele verbale, ordonate de la cel mai nou la cel mai vechi + protocols = HandoverProtocol.objects.all().order_by("-date", "-document_number") + return render(request, "equipment/handover_list.html", {"protocols": protocols}) + + +@login_required +def handover_protocol_form(request, pk=None): + # --- Restricție de securitate --- + if not request.user.is_staff: + raise PermissionDenied( + "Nu aveți permisiunea de a genera procese verbale de predare-primire." + ) + + if pk: + protocol = get_object_or_404(HandoverProtocol, pk=pk) + else: + protocol = None + + if request.method == "POST": + # ATENȚIE: request.FILES este obligatoriu pentru a prelua documentul PDF/Scanat + form = HandoverProtocolForm(request.POST, request.FILES, instance=protocol) + + if form.is_valid(): + protocol_instance = form.save(commit=False) + + # Completăm automat "Întocmit de (IT)" cu numele utilizatorului logat + if not protocol_instance.it_validator and request.user.is_authenticated: + protocol_instance.it_validator = ( + request.user.get_full_name() or request.user.username + ) + + protocol_instance.save() + + # ATENȚIE: Pentru câmpurile de tip SelectMultiple (Many-to-Many), + # trebuie apelată manual această funcție după salvarea instanței + form.save_m2m() + + # Preluăm noul posesor din formular + new_owner = protocol_instance.new_holder + + # Trecem toate echipamentele selectate în proprietatea noului posesor + if new_owner: + # Folosim .save() în loc de .update() pentru ca simple_history să înregistreze modificarea + for pc in protocol_instance.computers.all(): + pc.assigned_to = new_owner + pc.save() + + for mon in protocol_instance.monitors.all(): + mon.assigned_to = new_owner + mon.save() + + for per in protocol_instance.peripherals.all(): + per.assigned_to = new_owner + per.save() + + response = HttpResponse(status=204) + response["HX-Refresh"] = "true" + return response + else: + form = HandoverProtocolForm(instance=protocol) + + return render( + request, + "equipment/partials/handover_form.html", + {"form": form, "protocol": protocol}, + ) + + +@login_required +def dashboard_view(request): + # --- Coloana 1: Sumar Inventar (Înlocuitor temporar pt. Tichete) --- + total_computers = Computer.objects.count() + unassigned_computers = Computer.objects.filter(assigned_to__isnull=True) + unassigned_monitors = Monitor.objects.filter(assigned_to__isnull=True) + + # --- Coloana 2: Acțiuni Executate (Istoric simple_history) --- + # Preluăm ultimele 10 acțiuni din fiecare categorie + pc_hist = Computer.history.all()[:10] + mon_hist = Monitor.history.all()[:10] + per_hist = Peripheral.history.all()[:10] + + # Combinăm listele și le sortăm descrescător după data acțiunii (history_date) + activity_log = sorted( + chain(pc_hist, mon_hist, per_hist), key=attrgetter("history_date"), reverse=True + )[ + :10 + ] # Păstrăm doar ultimele 10 cele mai recente acțiuni în total + + # --- Coloana 3: Mentenanțe Efectuate --- + recent_maintenance = MaintenanceRecord.objects.all().order_by("-date", "-id")[:10] + + context = { + "total_computers": total_computers, + "unassigned_computers": unassigned_computers, + "unassigned_monitors": unassigned_monitors, + "activity_log": activity_log, + "recent_maintenance": recent_maintenance, + } + + return render(request, "equipment/dashboard.html", context) + + +@login_required +def generate_handover_pdf(request, pk): + protocol = get_object_or_404(HandoverProtocol, pk=pk) + + # Lăsăm Django să găsească calea absolută a fonturilor pe disc + font_regular = finders.find("fonts/LiberationSerif-Regular.ttf") + font_bold = finders.find("fonts/LiberationSerif-Bold.ttf") + + # Găsim calea către siglă + logo_path = finders.find("img/stema.png") + + # Definim contextul care va fi trimis către șablonul HTML + context = { + "protocol": protocol, + "font_regular": font_regular, + "font_bold": font_bold, + "logo_path": logo_path, # Trimitem calea siglei în context + # Preluăm numele complet (dacă e setat în admin), altfel luăm username-ul + "generated_by": request.user.get_full_name() or request.user.username, + } + + template = get_template("equipment/pdf_handover.html") + html = template.render(context) + + # --- REZOLVARE DIACRITICE --- + # Înlocuim "ș" și "ț" cu virgulă (standard nou) cu variantele cu sedilă (standard vechi) + # pentru a fi recunoscute de fontul din 2010 și librăria PDF. + html = html.replace("ș", "ş").replace("ț", "ţ") + html = html.replace("Ș", "Ş").replace("Ț", "Ţ") + # ---------------------------- + + # Creăm răspunsul HTTP de tip PDF + response = HttpResponse(content_type="application/pdf") + # Setăm numele fișierului descărcat + response["Content-Disposition"] = ( + f'attachment; filename="Proces_Verbal_Nr_{protocol.document_number}.pdf"' + ) + + # Generăm PDF-ul + pisa_status = pisa.CreatePDF(html, dest=response) + + if pisa_status.err: + return HttpResponse("A apărut o eroare la generarea PDF-ului", status=500) + return response + + +@login_required +def load_user_equipment(request): + user_id = request.GET.get("previous_holder") + form = HandoverProtocolForm() + + if user_id: + # Filtrăm doar echipamentele active ale utilizatorului selectat + form.fields["computers"].queryset = Computer.objects.filter( + assigned_to_id=user_id, status="activ" + ) + form.fields["monitors"].queryset = Monitor.objects.filter( + assigned_to_id=user_id, status="activ" + ) + form.fields["peripherals"].queryset = Peripheral.objects.filter( + assigned_to_id=user_id, status="activ" + ) + else: + form.fields["computers"].queryset = Computer.objects.none() + form.fields["monitors"].queryset = Monitor.objects.none() + form.fields["peripherals"].queryset = Peripheral.objects.none() + + # Trimitem doar bucata HTML cu cele 3 liste + return render( + request, "equipment/partials/equipment_dropdowns.html", {"form": form} + ) + + +@login_required +def decommission_list(request): + # Listăm toate procesele verbale de casare + records = DecommissionRecord.objects.all().order_by("-date", "-document_number") + return render(request, "equipment/decommission_list.html", {"records": records}) + + +@login_required +def decommission_form(request, pk=None): + # --- Restricție de securitate --- + if not request.user.is_staff: + raise PermissionDenied("Nu aveți permisiunea de a gestiona casările.") + + if pk: + record = get_object_or_404(DecommissionRecord, pk=pk) + else: + record = None + + if request.method == "POST": + form = DecommissionRecordForm(request.POST, instance=record) + + if form.is_valid(): + decommission_instance = form.save() + + # --- ACTUALIZARE AUTOMATĂ A INVENTARULUI --- + # Trecem toate echipamentele selectate pe status "casat" și le scoatem de la utilizatori + for pc in decommission_instance.computers.all(): + pc.status = "casat" + pc.assigned_to = None + pc.save() + + for mon in decommission_instance.monitors.all(): + mon.status = "casat" + mon.assigned_to = None + mon.save() + + for per in decommission_instance.peripherals.all(): + per.status = "casat" + per.assigned_to = None + per.save() + # ------------------------------------------- + + response = HttpResponse(status=204) + response["HX-Refresh"] = "true" + return response + else: + form = DecommissionRecordForm(instance=record) + + return render( + request, + "equipment/partials/decommission_form.html", + {"form": form, "record": record}, + ) + + +@login_required +def generate_decommission_pdf(request, pk): + record = get_object_or_404(DecommissionRecord, pk=pk) + + font_regular = finders.find("fonts/LiberationSerif-Regular.ttf") + font_bold = finders.find("fonts/LiberationSerif-Bold.ttf") + logo_path = finders.find("img/stema.png") + + context = { + "record": record, + "font_regular": font_regular, + "font_bold": font_bold, + "logo_path": logo_path, + "generated_by": request.user.get_full_name() or request.user.username, + } + + template = get_template("equipment/pdf_decommission.html") + html = template.render(context) + + # Rezolvare diacritice pentru fontul vechi + html = html.replace("ș", "ş").replace("ț", "ţ") + html = html.replace("Ș", "Ş").replace("Ț", "Ţ") + + response = HttpResponse(content_type="application/pdf") + response["Content-Disposition"] = ( + f'attachment; filename="PV_Casare_{record.document_number}.pdf"' + ) + + pisa_status = pisa.CreatePDF(html, dest=response) + + if pisa_status.err: + return HttpResponse("A apărut o eroare la generarea PDF-ului", status=500) + return response + + +@login_required +def certificate_list(request): + certificates = DigitalCertificate.objects.all().order_by("expiration_date") + + today = date.today() + warning_date = today + timedelta(days=30) + + # Calculăm statusul în timp real pentru fiecare certificat + for cert in certificates: + if cert.is_canceled: + cert.status_color = "secondary" + cert.status_text = "Anulat" + elif cert.expiration_date < today: + cert.status_color = "danger" + cert.status_text = "Expirat" + elif cert.expiration_date <= warning_date: + cert.status_color = "warning text-dark" + cert.status_text = "Expiră curând" + else: + cert.status_color = "success" + cert.status_text = "Activ" + + return render( + request, "equipment/certificate_list.html", {"certificates": certificates} + ) + + +@login_required +def certificate_form(request, pk=None): + # --- Restricție de securitate --- + if not request.user.is_staff: + raise PermissionDenied( + "Nu aveți permisiunea de a adăuga sau edita certificate digitale." + ) + + if pk: + certificate = get_object_or_404(DigitalCertificate, pk=pk) + else: + certificate = None + + if request.method == "POST": + form = DigitalCertificateForm(request.POST, instance=certificate) + if form.is_valid(): + form.save() + response = HttpResponse(status=204) + response["HX-Refresh"] = "true" + return response + else: + form = DigitalCertificateForm(instance=certificate) + + return render( + request, + "equipment/partials/certificate_form.html", + {"form": form, "certificate": certificate}, + ) + + +@login_required +def export_inventory_csv(request): + response = HttpResponse(content_type="text/csv; charset=utf-8-sig") + response["Content-Disposition"] = 'attachment; filename="Inventar_Echipamente.csv"' + + writer = csv.writer(response, delimiter=";") + + # ========================================== + # 1. SECȚIUNEA COMPUTERE + # ========================================== + writer.writerow(["--- COMPUTERE ---"]) + writer.writerow( + [ + "Cod Inventar", + "OEM / Model", + "Placă de bază", + "Procesor (CPU)", + "Memorie RAM", + "Stocare", + "Adresă MAC", + "Utilizator Curent", + "Status", + ] + ) + + computers = Computer.objects.filter(status="activ").order_by("assigned_to__name") + for pc in computers: + owner = pc.assigned_to.name if pc.assigned_to else "Magazie (Neasignat)" + writer.writerow( + [ + pc.equipment_code, + pc.oem_name or "Asamblat", + pc.motherboard, + pc.cpu, + pc.ram, + pc.storage, + pc.mac_address, + owner, + pc.get_status_display(), + ] + ) + + writer.writerow([]) # Rând liber pentru spațiere + writer.writerow([]) + + # ========================================== + # 2. SECȚIUNEA MONITOARE + # ========================================== + writer.writerow(["--- MONITOARE ---"]) + writer.writerow( + [ + "Cod Inventar", + "Model / Nume", + "Diagonală", + "Număr Serial", + "Utilizator Curent", + "Status", + ] + ) + + monitors = Monitor.objects.filter(status="activ").order_by("assigned_to__name") + for mon in monitors: + owner = mon.assigned_to.name if mon.assigned_to else "Magazie (Neasignat)" + writer.writerow( + [ + mon.equipment_code, + mon.name, + mon.screen_diagonal, + mon.serial_number, + owner, + mon.get_status_display(), + ] + ) + + writer.writerow([]) # Rând liber pentru spațiere + writer.writerow([]) + + # ========================================== + # 3. SECȚIUNEA PERIFERICE + # ========================================== + writer.writerow(["--- PERIFERICE ---"]) + writer.writerow( + [ + "Cod Inventar", + "Tip Echipament", + "Model / Nume", + "Număr Serial", + "Utilizator Curent", + "Status", + ] + ) + + peripherals = Peripheral.objects.filter(status="activ").order_by( + "assigned_to__name" + ) + for per in peripherals: + owner = per.assigned_to.name if per.assigned_to else "Magazie (Neasignat)" + writer.writerow( + [ + per.equipment_code, + per.get_peripheral_type_display(), + per.name, + per.serial_number, + owner, + per.get_status_display(), + ] + ) + + return response diff --git a/eumi__v4_project/settings.py b/eumi__v4_project/settings.py index d2ae6b8..2cecb5c 100644 --- a/eumi__v4_project/settings.py +++ b/eumi__v4_project/settings.py @@ -63,7 +63,7 @@ ROOT_URLCONF = "eumi__v4_project.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -115,7 +115,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/6.1/topics/i18n/ -LANGUAGE_CODE = "ro" +LANGUAGE_CODE = "en-us" TIME_ZONE = "Europe/Bucharest" @@ -128,6 +128,9 @@ USE_TZ = True # https://docs.djangoproject.com/en/6.1/howto/static-files/ STATIC_URL = "static/" +STATICFILES_DIRS = [ + BASE_DIR / "static", +] # Email @@ -138,3 +141,13 @@ MAILERS = { "BACKEND": "django.core.mail.backends.console.EmailBackend", }, } + +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +# Setări pentru Autentificare +LOGIN_REDIRECT_URL = "/" + +LOGOUT_REDIRECT_URL = ( + "/accounts/login/" # După logout, se întoarce la formularul de conectare +) diff --git a/eumi__v4_project/urls.py b/eumi__v4_project/urls.py index 95d7856..40ab744 100644 --- a/eumi__v4_project/urls.py +++ b/eumi__v4_project/urls.py @@ -14,9 +14,24 @@ 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 +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ - path('admin/', admin.site.urls), + path("admin/", admin.site.urls), + # Rutele standard Django pentru login/logout + path("accounts/", include("django.contrib.auth.urls")), + # Rutele aplicațiilor + # path("equipment/", include("equipment.urls")), + path("users/", include("users.urls")), + path("interventions/", include("interventions.urls")), + # Poți seta temporar echipamentele ca pagină de start dacă dorești, + # până vom construi Dashboard-ul + path("", include("equipment.urls")), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/interventions/templates/interventions/intervention_list.html b/interventions/templates/interventions/intervention_list.html new file mode 100644 index 0000000..660d5d4 --- /dev/null +++ b/interventions/templates/interventions/intervention_list.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Intervenții (Helpdesk)

+ + + +
+ +
+ + + + + + + + + + + + + {% include 'interventions/partials/ticket_table.html' %} + +
IDSubiect & DescriereRaportat deStatusPrioritateData Raportării
+
+{% endblock %} diff --git a/interventions/templates/interventions/partials/ticket_table.html b/interventions/templates/interventions/partials/ticket_table.html new file mode 100644 index 0000000..d18319c --- /dev/null +++ b/interventions/templates/interventions/partials/ticket_table.html @@ -0,0 +1,41 @@ +{% for ticket in tickets %} + + #{{ ticket.id }} + + {{ ticket.title }}
+ + {{ ticket.description }} + + + {{ ticket.reported_by.name }} + + {% if ticket.status == 'nou' %} + Nou (Deschis) + {% elif ticket.status == 'in_lucru' %} + În lucru + {% elif ticket.status == 'in_asteptare' %} + În așteptare + {% elif ticket.status == 'solutionat' %} + Soluționat + {% else %} + Închis + {% endif %} + + + {% if ticket.priority == 'scazuta' %} + Scăzută + {% elif ticket.priority == 'medie' %} + Medie + {% elif ticket.priority == 'ridicata' %} + Ridicată + {% else %} + Critică + {% endif %} + + {{ ticket.created_at|date:"d.m.Y H:i" }} + +{% empty %} + + Nu au fost găsite tichete de suport. + +{% endfor %} diff --git a/interventions/tests.py b/interventions/tests.py index 7ce503c..e1e18cd 100644 --- a/interventions/tests.py +++ b/interventions/tests.py @@ -1,3 +1,27 @@ from django.test import TestCase +from users.models import Employee +from .models import Intervention -# Create your tests here. + +class InterventionModelTests(TestCase): + def setUp(self): + self.emp = Employee.objects.create( + name="Angajat Test", + username="angajat.test", + role="Consilier", + office_number="10", + email="test@primariaroman.ro", + ) + + def test_intervention_defaults(self): + # Create a ticket providing only the required fields + ticket = Intervention.objects.create( + title="Problemă Rețea", + description="Nu mă pot conecta la internet.", + reported_by=self.emp, + ) + + # Verify default values are correctly applied + self.assertEqual(ticket.status, "nou") + self.assertEqual(ticket.priority, "medie") + self.assertIsNone(ticket.assigned_to_it) diff --git a/interventions/urls.py b/interventions/urls.py new file mode 100644 index 0000000..33bc24f --- /dev/null +++ b/interventions/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +app_name = "interventions" + +urlpatterns = [ + path("", views.intervention_list, name="intervention_list"), +] diff --git a/interventions/views.py b/interventions/views.py index 91ea44a..3ed4826 100644 --- a/interventions/views.py +++ b/interventions/views.py @@ -1,3 +1,23 @@ from django.shortcuts import render +from django.db.models import Q +from .models import Intervention -# Create your views here. + +def intervention_list(request): + query = request.GET.get("search", "") + tickets = Intervention.objects.all().order_by("-created_at") + + if query: + tickets = tickets.filter( + Q(title__icontains=query) + | Q(description__icontains=query) + | Q(reported_by__name__icontains=query) + | Q(assigned_to_it__icontains=query) + ) + + context = {"tickets": tickets} + + if request.headers.get("HX-Request"): + return render(request, "interventions/partials/ticket_table.html", context) + + return render(request, "interventions/intervention_list.html", context) diff --git a/media/handover_protocols/BozL.pdf b/media/handover_protocols/BozL.pdf new file mode 100644 index 0000000..a1af0d6 Binary files /dev/null and b/media/handover_protocols/BozL.pdf differ diff --git a/requirements.txt b/requirements.txt index 6757a74..1e7bde7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ Django==6.1 psycopg2-binary==2.9.12 sqlparse==0.6.0 django-simple-history - +xhtml2pdf>=0.2.11 diff --git a/static/fonts/LiberationSerif-Bold.ttf b/static/fonts/LiberationSerif-Bold.ttf new file mode 100644 index 0000000..a783c3a Binary files /dev/null and b/static/fonts/LiberationSerif-Bold.ttf differ diff --git a/static/fonts/LiberationSerif-Regular.ttf b/static/fonts/LiberationSerif-Regular.ttf new file mode 100644 index 0000000..88b7a20 Binary files /dev/null and b/static/fonts/LiberationSerif-Regular.ttf differ diff --git a/static/img/stema.png b/static/img/stema.png new file mode 100755 index 0000000..29be522 Binary files /dev/null and b/static/img/stema.png differ diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..9f0dee1 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,107 @@ +{% load static %} + + + + + + EUMI - Management IT + + + + + + + + + + + + +
+ +
+ Stema Roman +
+
U.A.T. Municipiul Roman
+
Compartiment administrare infrastructură I.T. și baze de date
+
+
+ {% block content %} + + {% endblock %} +
+
+
+ Aplicație dezvoltată de Tololoi Cătălin | + ✉️ catalin@x3c.ro +
+
+ + + + + + + diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..f7288c6 --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+
+ + + + +
+
+ Autentificare +
+
+ {% if form.errors %} +
+ Utilizator sau parolă incorectă. Vă rugăm să încercați din nou. +
+ {% endif %} + +
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ +
+
+{% endblock %} diff --git a/users/admin.py b/users/admin.py index c725e22..99fa59f 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse from simple_history.admin import SimpleHistoryAdmin from .models import Directie, Serviciu, Compartiment, Employee @@ -33,6 +35,18 @@ class EmployeeAdmin(SimpleHistoryAdmin): "directie", "serviciu", "compartiment", + "print_inventory_button", ) list_filter = ("directie", "serviciu", "compartiment") search_fields = ("name", "username", "email") + + def print_inventory_button(self, obj): + if obj.pk: + url = reverse("equipment:print_inventory", args=[obj.pk]) + return format_html( + '🖨️ Printează Fișa', + url, + ) + return "-" + + print_inventory_button.short_description = "Acțiune" diff --git a/users/forms.py b/users/forms.py new file mode 100644 index 0000000..a5e0334 --- /dev/null +++ b/users/forms.py @@ -0,0 +1,29 @@ +from django import forms +from .models import Employee + + +class EmployeeForm(forms.ModelForm): + class Meta: + model = Employee + fields = [ + "name", + "username", + "role", + "office_number", + "email", + "directie", + "serviciu", + "compartiment", + "observations", + ] + widgets = { + "name": forms.TextInput(attrs={"class": "form-control"}), + "username": forms.TextInput(attrs={"class": "form-control"}), + "role": forms.TextInput(attrs={"class": "form-control"}), + "office_number": forms.TextInput(attrs={"class": "form-control"}), + "email": forms.EmailInput(attrs={"class": "form-control"}), + "directie": forms.Select(attrs={"class": "form-select"}), + "serviciu": forms.Select(attrs={"class": "form-select"}), + "compartiment": forms.Select(attrs={"class": "form-select"}), + "observations": forms.Textarea(attrs={"class": "form-control", "rows": 2}), + } diff --git a/users/migrations/0003_alter_employee_observations_and_more.py b/users/migrations/0003_alter_employee_observations_and_more.py new file mode 100644 index 0000000..8a02490 --- /dev/null +++ b/users/migrations/0003_alter_employee_observations_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.1 on 2026-08-26 11:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_historicalcompartiment_historicaldirectie_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='employee', + name='observations', + field=models.TextField(blank=True, null=True, verbose_name='Observații'), + ), + migrations.AlterField( + model_name='historicalemployee', + name='observations', + field=models.TextField(blank=True, null=True, verbose_name='Observații'), + ), + ] diff --git a/users/models.py b/users/models.py index 8c0c38f..cdc640d 100644 --- a/users/models.py +++ b/users/models.py @@ -89,7 +89,9 @@ class Employee(models.Model): role = models.CharField(max_length=255, verbose_name="Function/role") office_number = models.CharField(max_length=50, verbose_name="Office number") email = models.EmailField(verbose_name="Email address") - observations = models.TextField(null=True, blank=True, verbose_name="Observations") + + # Keep only this single observations field + observations = models.TextField(blank=True, null=True, verbose_name="Observații") # Organizational Placement - Employees can be placed at any of the three levels directie = models.ForeignKey( @@ -116,6 +118,17 @@ class Employee(models.Model): related_name="employees", verbose_name="Compartiment", ) + + @property + def get_active_inventory(self): + """Returns a dictionary of all active equipment and certificates assigned to the user.""" + return { + "computers": self.computers.filter(status="activ"), + "monitors": self.monitors.filter(status="activ"), + "peripherals": self.peripherals.filter(status="activ"), + "certificates": self.digital_certificates.filter(is_canceled=False), + } + history = HistoricalRecords() def __str__(self): diff --git a/users/templates/users/partials/employee_form.html b/users/templates/users/partials/employee_form.html new file mode 100644 index 0000000..4d7b25e --- /dev/null +++ b/users/templates/users/partials/employee_form.html @@ -0,0 +1,52 @@ + +
+ {% csrf_token %} + + +
diff --git a/users/templates/users/partials/user_table.html b/users/templates/users/partials/user_table.html new file mode 100644 index 0000000..b07eb4e --- /dev/null +++ b/users/templates/users/partials/user_table.html @@ -0,0 +1,31 @@ +{% for emp in employees %} + + {{ emp.name }} + {{ emp.username }} + {{ emp.role }} + + {% if emp.directie %}{{ emp.directie.name }}
{% endif %} + {% if emp.serviciu %}{{ emp.serviciu.name }}
{% endif %} + {% if emp.compartiment %}{{ emp.compartiment.name }}{% endif %} + + + + {% if user.is_staff %} + + {% endif %} + + + 🖨️ Fișă + + + +{% empty %} + + Nu au fost găsiți angajați conform criteriilor de căutare. + +{% endfor %} diff --git a/users/templates/users/user_list.html b/users/templates/users/user_list.html new file mode 100644 index 0000000..d42e893 --- /dev/null +++ b/users/templates/users/user_list.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Angajați

+ +
+ + + + + {% if user.is_staff %} + + {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% include 'users/partials/user_table.html' %} + +
NumeUsernameFuncțieStructură (Dir / Serv / Comp)Acțiuni
+
+ + + + + + +{% endblock %} diff --git a/users/tests.py b/users/tests.py index 7ce503c..d865e71 100644 --- a/users/tests.py +++ b/users/tests.py @@ -1,3 +1,45 @@ from django.test import TestCase +from django.core.exceptions import ValidationError +from .models import Directie, Serviciu, Compartiment, Employee -# Create your tests here. + +class UsersModelTests(TestCase): + def setUp(self): + self.directie = Directie.objects.create(name="Direcția Economică") + self.serviciu = Serviciu.objects.create( + name="Serviciul Contabilitate", directie=self.directie + ) + + def test_compartiment_validation_valid(self): + # A Compartiment assigned to a Serviciu should pass validation + comp = Compartiment(name="Compartiment Salarizare", serviciu=self.serviciu) + comp.full_clean() # Validates the model without saving to DB + self.assertEqual(comp.serviciu, self.serviciu) + + def test_compartiment_validation_missing_parent(self): + # A Compartiment with no parent should raise a ValidationError + comp = Compartiment(name="Compartiment Orfan") + with self.assertRaises(ValidationError): + comp.full_clean() + + def test_compartiment_validation_multiple_parents(self): + # A Compartiment assigned to BOTH a Serviciu and a Directie should fail + comp = Compartiment( + name="Compartiment Dublu Subordonat", + directie=self.directie, + serviciu=self.serviciu, + ) + with self.assertRaises(ValidationError): + comp.full_clean() + + def test_employee_creation(self): + emp = Employee.objects.create( + name="Test User", + username="testuser", + role="Inspector", + office_number="12", + email="test@primariaroman.ro", + directie=self.directie, + ) + self.assertEqual(emp.name, "Test User") + self.assertEqual(str(emp), "Test User (testuser)") diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..2230ae5 --- /dev/null +++ b/users/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +app_name = "users" + +urlpatterns = [ + path("", views.user_list, name="user_list"), + path("new/", views.employee_form, name="employee_create"), + path("/edit/", views.employee_form, name="employee_edit"), +] diff --git a/users/views.py b/users/views.py index 91ea44a..48b1845 100644 --- a/users/views.py +++ b/users/views.py @@ -1,3 +1,54 @@ -from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, get_object_or_404 +from django.http import HttpResponse +from django.db.models import Q +from .models import Employee +from .forms import EmployeeForm -# Create your views here. + +@login_required +def user_list(request): + query = request.GET.get("search", "") + employees = Employee.objects.all().order_by("name") + + if query: + # Filter by name, username, or role (case-insensitive) + employees = employees.filter( + Q(name__icontains=query) + | Q(username__icontains=query) + | Q(role__icontains=query) + ) + + context = {"employees": employees} + + # If the request comes from HTMX, only return the table rows + if request.headers.get("HX-Request"): + return render(request, "users/partials/user_table.html", context) + + # Otherwise, return the full page + return render(request, "users/user_list.html", context) + + +@login_required +def employee_form(request, pk=None): + if pk: + employee = get_object_or_404(Employee, pk=pk) + else: + employee = None + + if request.method == "POST": + form = EmployeeForm(request.POST, instance=employee) + if form.is_valid(): + form.save() + # Return a 204 No Content response, but trigger custom HTMX events + response = HttpResponse(status=204) + response["HX-Trigger"] = "employeeListChanged, closeModal" + return response + else: + form = EmployeeForm(instance=employee) + + return render( + request, + "users/partials/employee_form.html", + {"form": form, "employee": employee}, + )