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 %}
+
+ ➕ Adaugă Certificat
+
+ {% endif %}
+
+
+
+
+
+
+ Titular
+ Tip
+ Dată Emitere
+ Dată Expirare
+ Status
+ Acțiuni
+
+
+
+ {% for cert in certificates %}
+
+ 👤 {{ 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 %}
+
+ ✏️ Editează
+ {% endif %}
+
+
+
+ {% empty %}
+
+ Nu există certificate digitale înregistrate.
+
+ {% endfor %}
+
+
+
+
+
+
+{% 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.
+
+
+
+
+
+
+
+
+
+
+
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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% 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 %}
+
+ ➕ Adaugă PV Casare
+
+ {% endif %}
+
+
+
+
+
+
+ Număr PV
+ Data
+ Motiv
+ Echipamente Casate
+ Acțiuni
+
+
+
+ {% for record in records %}
+
+ # {{ 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 %}
+
+ ✏️ Editează
+
+ {% endif %}
+
+
+ {% empty %}
+
+ Nu există procese verbale de casare înregistrate.
+
+ {% endfor %}
+
+
+
+
+
+
+{% 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
+
+
+
+
+
+
+
+ {% if user.is_staff %}
+
+ + Adaugă Echipament
+
+ {% endif %}
+
+
+
+ 📊 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 %}
+
+ + Adaugă Proces Verbal
+
+ {% endif %}
+
+
+
+
+
+
+
+ Nr. / Dată
+ Predat de
+ Primit de
+ Echipamente transferate
+ Document Scanat
+ Acțiuni
+
+
+
+ {% for protocol in protocols %}
+
+ 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
+
+
+
+
+ ✏️ Editează
+
+
+
+ {% empty %}
+
+ Nu există procese verbale înregistrate.
+
+ {% endfor %}
+
+
+
+
+
+
+
+{% 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
+
+
+
+
+
+
+
+ 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:"-" }}
+
+
+
+
+
+
+
+
+ Dată Tip Operațiune Efectuat de
+
+ {% for log in maintenance_logs %}
+ {{ log.date|date:"d.m.Y" }} {{ log.operation_type }}{{ log.description }} {{ log.performed_by }}
+ {% empty %}
+ Nu există intervenții de mentenanță.
+ {% endfor %}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
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 @@
+
+
+
+
+
+
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 @@
+
+
Computere
+ {{ form.computers }}
+
Folosiți CTRL pt. selecție multiplă.
+
+
+ Monitoare
+ {{ form.monitors }}
+
+
+ Periferice
+ {{ 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 @@
+
+
+
+ Computere / Laptopuri {{ computers.count }}
+
+
+
+
+ Monitoare {{ monitors.count }}
+
+
+
+
+ Periferice {{ peripherals.count }}
+
+
+
+
+
+
+
+
+
+
+
+ Cod
+ Detalii Sistem
+ Asignat Către
+ Data Intrării
+ Acțiuni
+
+
+
+ {% for pc in computers %}
+
+ {{ 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 %}
+
+ ✏️ Editează
+
+ {% endif %}
+
+
+ {% empty %}
+ Nu există computere active.
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+ Cod
+ Model & S/N
+ Asignat Către
+ Data Intrării
+ Acțiuni
+
+
+
+ {% for monitor in monitors %}
+
+ {{ 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 %}
+
+ ✏️ Editează
+
+ {% endif %}
+
+
+ {% empty %}
+ Nu există monitoare active.
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+ Cod
+ Tip & Model
+ Asignat Către
+ Data Intrării
+ Acțiuni
+
+
+
+ {% for peripheral in peripherals %}
+
+ {{ 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 %}
+
+ ✏️ Editează
+
+ {% endif %}
+
+
+ {% empty %}
+ Nu există periferice active.
+ {% endfor %}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
\ 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 @@
+
+
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 @@
+
+
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:
+
+
+ {% for pc in record.computers.all %}
+
+ unitate centrală, nr. inventar {{ pc.equipment_code }}, caracteristici:
+ {{ pc.cpu|default:"-" }} | {{ pc.ram|default:"-" }} | {{ pc.motherboard|default:"-" }}.
+
+ {% endfor %}
+
+ {% for mon in record.monitors.all %}
+
+ monitor, nr. inventar {{ mon.equipment_code }}, model: {{ mon.name|default:"-" }}.
+
+ {% endfor %}
+
+ {% for per in record.peripherals.all %}
+
+ periferic ({{ per.get_peripheral_type_display }}), nr. inventar {{ per.equipment_code }}, model: {{ per.name|default:"-" }}.
+
+ {% endfor %}
+
+
+ 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:
+
+
+ {% for pc in protocol.computers.all %}
+
+ unitate centrală, nr. inventar {{ pc.equipment_code }}, caracteristici:
+ {{ pc.cpu|default:"-" }} | {{ pc.motherboard|default:"-" }} |
+ {{ pc.ram|default:"-" }} | {{ pc.storage|default:"-" }} |
+ {{ pc.mac_address|default:"-" }};
+
+ {% endfor %}
+
+ {% for mon in protocol.monitors.all %}
+
+ monitor, nr. inventar {{ mon.equipment_code }}, {{ mon.name|default:"-" }},
+ s/n {{ mon.serial_number|default:"-" }};
+
+ {% endfor %}
+
+ {% for per in protocol.peripherals.all %}
+
+ periferic ({{ per.get_peripheral_type_display }}), nr. inventar {{ per.equipment_code }},
+ {{ per.name|default:"-" }}, s/n {{ per.serial_number|default:"-" }};
+
+ {% endfor %}
+
+
+ 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
+
+
+
+
+
+
+
+ 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:"-" }}
+
+
+
+
+
+
+
+
+ Dată Tip Operațiune Efectuat de
+
+ {% for log in maintenance_logs %}
+ {{ log.date|date:"d.m.Y" }} {{ log.operation_type }}{{ log.description }} {{ log.performed_by }}
+ {% empty %}
+ Nu există intervenții de mentenanță.
+ {% endfor %}
+
+
+
+
+
+
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 }}
+
+
+
+
+
+ Printează PV
+
+
+ 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 }}
+
+
+
+
+
+
+
+
+
+
U.A.T. Municipiul Roman
+
Compartiment administrare infrastructură I.T. și baze de date
+
+
+
Printează Fișa
+
+
+ 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
+
+
+
+ Cod Echipament
+ Tip
+ Detalii / Producător
+
+
+
+ {% for pc in inventory.computers %}
+
+ {{ 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 %}
+
+
+
+ {% endfor %}
+
+ {% for monitor in inventory.monitors %}
+
+ {{ monitor.equipment_code }}
+ Monitor
+
+ {{ monitor.name }}
+
+ Diagonală: {{ monitor.screen_diagonal }} | S/N: {{ monitor.serial_number }}
+ {% if monitor.observations %}Observații: {{ monitor.observations }}{% endif %}
+
+
+
+ {% endfor %}
+
+ {% for peripheral in inventory.peripherals %}
+
+ {{ peripheral.equipment_code }}
+ {{ peripheral.get_peripheral_type_display }}
+
+ {{ peripheral.name }}
+
+ S/N: {{ peripheral.serial_number }}
+
+
+
+ {% endfor %}
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+ 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" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dată
+ Componentă
+ Valoare Veche
+ Valoare Nouă
+
+
+
+ {% for change in hardware_changes %}
+
+ {{ change.date|date:"d.m.Y H:i" }}
+ {{ change.field }}
+ {{ change.old|default:"-" }}
+ {{ change.new|default:"-" }}
+
+ {% empty %}
+ Nu există modificări hardware înregistrate.
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+ Dată
+ Tip Operațiune
+ Efectuat de
+
+
+
+ {% for log in maintenance_logs %}
+
+ {{ log.date|date:"d.m.Y" }}
+ {{ log.operation_type }}{{ log.description }}
+ {{ log.performed_by }}
+
+ {% empty %}
+ Nu există intervenții de mentenanță.
+ {% endfor %}
+
+
+
+
+
+
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)
+
+
+
+
+
+
+
+
+
+ ID
+ Subiect & Descriere
+ Raportat de
+ Status
+ Prioritate
+ Data Raportării
+
+
+
+ {% include 'interventions/partials/ticket_table.html' %}
+
+
+
+{% 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
+
+
+
+
+
+
+
+
+
+
+
+
E.U.M.I. v.4.0.0
+
+
+
+
+
+
+ {% if user.is_authenticated %}
+
+ Salut, {{ user.username }}
+
+
+
+
+ {% else %}
+
+ Compartimentul IT
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
U.A.T. Municipiul Roman
+
Compartiment administrare infrastructură I.T. și baze de date
+
+
+ {% block content %}
+
+ {% endblock %}
+
+
+
+
+
+
+
+
+
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 %}
+
+
+
+
+
+
+
+
+
+ {% if form.errors %}
+
+ Utilizator sau parolă incorectă. Vă rugăm să încercați din nou.
+
+ {% endif %}
+
+
+
+
+
+
+
+{% 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 @@
+
+
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 %}
+
+ ✏️ Editează
+
+ {% 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 %}
+
+ + Adaugă Angajat
+
+ {% endif %}
+
+
+
+
+
+
+
+ Nume
+ Username
+ Funcție
+ Structură (Dir / Serv / Comp)
+ Acțiuni
+
+
+
+
+ {% include 'users/partials/user_table.html' %}
+
+
+
+
+
+
+
+
+
+{% 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},
+ )