File & Photo Uploads & Excel Import/Export
This commit is contained in:
parent
50d8918174
commit
a200f58095
Binary file not shown.
Binary file not shown.
@ -155,6 +155,10 @@ STATICFILES_DIRS = [
|
|||||||
BASE_DIR / 'node_modules',
|
BASE_DIR / 'node_modules',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Media files (Uploads)
|
||||||
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EMAIL_BACKEND = os.getenv(
|
EMAIL_BACKEND = os.getenv(
|
||||||
"EMAIL_BACKEND",
|
"EMAIL_BACKEND",
|
||||||
|
|||||||
@ -27,3 +27,4 @@ urlpatterns = [
|
|||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from .models import Company, JobStatus, RequiredFolder, Job
|
from .models import Company, JobStatus, RequiredFolder, Job, JobFile
|
||||||
|
|
||||||
class CompanyForm(forms.ModelForm):
|
class CompanyForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -65,3 +65,14 @@ class JobForm(forms.ModelForm):
|
|||||||
if not uprn:
|
if not uprn:
|
||||||
return None
|
return None
|
||||||
return uprn
|
return uprn
|
||||||
|
|
||||||
|
class JobFileForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = JobFile
|
||||||
|
fields = ['file']
|
||||||
|
widgets = {
|
||||||
|
'file': forms.ClearableFileInput(attrs={'class': 'form-control'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportJobsForm(forms.Form):
|
||||||
|
file = forms.FileField(label="Excel/CSV File", widget=forms.ClearableFileInput(attrs={'class': 'form-control'}))
|
||||||
@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
{% block title %}Job: {{ job.job_ref }} - RepairsHub{% endblock %}
|
{% block title %}Job: {{ job.job_ref }} - RepairsHub{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
@ -81,24 +82,94 @@
|
|||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for completion in folder_completions %}
|
{% for completion in folder_completions %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-12">
|
||||||
<div class="card h-100 border-0 shadow-sm {% if completion.is_completed %}border-start border-4 border-success{% endif %}">
|
<div class="card border-0 shadow-sm {% if completion.is_completed %}border-start border-4 border-success{% endif %} mb-3">
|
||||||
<div class="card-body d-flex justify-content-between align-items-center py-4">
|
<div class="card-body p-4">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h6 class="mb-1 fw-bold">{{ completion.folder.name }}</h6>
|
<div>
|
||||||
<p class="text-muted smaller mb-0">
|
<h6 class="mb-1 fw-bold fs-5">{{ completion.folder.name }}</h6>
|
||||||
<i class="bi bi-file-earmark-arrow-up"></i> 0 Files
|
<p class="text-muted smaller mb-0">
|
||||||
</p>
|
<i class="bi bi-file-earmark-text"></i> {{ completion.files_list.count }} Files uploaded
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#uploadModal{{ completion.folder.id }}">
|
||||||
|
<i class="bi bi-upload me-1"></i> Upload
|
||||||
|
</button>
|
||||||
|
<form action="{% url 'toggle_folder_completion' job.pk completion.folder.id %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm {% if completion.is_completed %}btn-success{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
|
||||||
|
{% if completion.is_completed %}
|
||||||
|
<i class="bi bi-check2-circle me-1"></i> Done
|
||||||
|
{% else %}
|
||||||
|
Mark Ready
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'toggle_folder_completion' job.pk completion.folder.id %}" method="post">
|
|
||||||
|
{% if completion.files_list %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>File Name</th>
|
||||||
|
<th>Uploaded By</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for job_file in completion.files_list %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ job_file.file.url }}" target="_blank" class="text-decoration-none">
|
||||||
|
<i class="bi bi-file-earmark-arrow-down me-2"></i> {{ job_file.file.name|cut:"job_files/" }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><small>{{ job_file.uploaded_by.username }}</small></td>
|
||||||
|
<td><small class="text-muted">{{ job_file.uploaded_at|date:"d/m/y H:i" }}</small></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{% url 'job_delete_file' job.pk job_file.id %}" class="btn btn-link text-danger p-0" onclick="return confirm('Delete this file?')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3 bg-light rounded-3">
|
||||||
|
<p class="text-muted small mb-0">No files uploaded yet.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div class="modal fade" id="uploadModal{{ completion.folder.id }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header border-0 bg-light">
|
||||||
|
<h5 class="modal-title">Upload to {{ completion.folder.name }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'job_upload_file' job.pk completion.folder.id %}" method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-sm {% if completion.is_completed %}btn-success{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
|
<div class="modal-body p-4">
|
||||||
{% if completion.is_completed %}
|
<div class="mb-3">
|
||||||
<i class="bi bi-check2-circle me-1"></i> Done
|
<label class="form-label fw-bold">Select File</label>
|
||||||
{% else %}
|
{{ file_form.file }}
|
||||||
Mark Ready
|
<div class="form-text">Photos, PDFs, or documents are accepted.</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
<div class="modal-footer border-0 p-4 pt-0">
|
||||||
|
<button type="button" class="btn btn-light rounded-pill px-4" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary rounded-pill px-4">Upload File</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -134,13 +205,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm bg-navy-dark text-white" style="background-color: var(--navy-dark);">
|
<div class="card border-0 shadow-sm bg-navy-dark text-white" style="background-color: #0F172A;">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-3">Quick Export</h6>
|
<h6 class="text-uppercase text-muted smaller fw-bold mb-3 text-white-50">Quick Export</h6>
|
||||||
<p class="small text-muted mb-4">Download this job details for offline reporting.</p>
|
<p class="small text-white-50 mb-4">Download this job details for offline reporting.</p>
|
||||||
<button class="btn btn-primary w-100 disabled" title="Coming soon">
|
<a href="{% url 'job_export' %}" class="btn btn-primary w-100 rounded-pill">
|
||||||
<i class="bi bi-file-earmark-pdf me-2"></i> Export to PDF
|
<i class="bi bi-file-earmark-excel me-2"></i> Export to Excel
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
72
core/templates/core/job_import.html
Normal file
72
core/templates/core/job_import.html
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Import Jobs - RepairsHub{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-decoration-none">Jobs</a></li>
|
||||||
|
<li class="breadcrumb-item active">Import</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-lg overflow-hidden">
|
||||||
|
<div class="bg-primary p-4 text-white">
|
||||||
|
<h2 class="h4 mb-1">Import Jobs</h2>
|
||||||
|
<p class="mb-0 opacity-75">Upload an Excel or CSV file to bulk create jobs.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-5">
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-4" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<h6 class="text-uppercase text-muted smaller fw-bold mb-3">Instructions</h6>
|
||||||
|
<ul class="small text-muted mb-0">
|
||||||
|
<li>Supported formats: <strong>.xlsx, .xls, .csv</strong></li>
|
||||||
|
<li>The following columns are recognized:
|
||||||
|
<ul class="mt-2">
|
||||||
|
<li><code>Job Ref</code> (Required, Unique)</li>
|
||||||
|
<li><code>UPRN</code> (Optional)</li>
|
||||||
|
<li><code>Address 1</code> (Required)</li>
|
||||||
|
<li><code>Postcode</code> (Required)</li>
|
||||||
|
<li><code>Status</code> (Optional, defaults to starting status)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Duplicate <code>Job Ref</code> will update existing jobs.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">{{ form.file.label }}</label>
|
||||||
|
{{ form.file }}
|
||||||
|
{% if form.file.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ form.file.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg rounded-pill">
|
||||||
|
<i class="bi bi-cloud-upload me-2"></i> Process Import
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'job_list' %}" class="btn btn-link text-muted">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<p class="text-muted small">Need a template? <a href="{% url 'job_export' %}" class="text-decoration-none">Export existing jobs</a> to see the required format.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -7,9 +7,20 @@
|
|||||||
<h1 class="h2 mb-1">Repair Jobs</h1>
|
<h1 class="h2 mb-1">Repair Jobs</h1>
|
||||||
<p class="text-muted">Manage and track all repairs for {{ company.name }}</p>
|
<p class="text-muted">Manage and track all repairs for {{ company.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'job_create' %}" class="btn btn-primary">
|
<div class="d-flex gap-2">
|
||||||
<i class="bi bi-plus-lg"></i> Create New Job
|
<div class="dropdown">
|
||||||
</a>
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="bulkActionsDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-gear me-1"></i> Bulk Actions
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end border-0 shadow" aria-labelledby="bulkActionsDropdown">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'job_import' %}"><i class="bi bi-cloud-upload me-2 text-primary"></i> Import Jobs</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'job_export' %}"><i class="bi bi-file-earmark-excel me-2 text-success"></i> Export All to Excel</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'job_create' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i> Create New Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
@ -21,7 +32,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
<div class="card border-0 shadow-sm overflow-hidden">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@ -44,7 +55,7 @@
|
|||||||
<td>{{ job.uprn }}</td>
|
<td>{{ job.uprn }}</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
<td>
|
||||||
<div class="small">{{ job.address_line_1 }}</div>
|
<div class="small fw-medium">{{ job.address_line_1 }}</div>
|
||||||
<div class="text-muted small">{{ job.postcode }}</div>
|
<div class="text-muted small">{{ job.postcode }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -54,17 +65,24 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">{{ job.created_at|date:"M d, Y" }}</td>
|
<td class="text-muted small">{{ job.created_at|date:"M d, Y" }}</td>
|
||||||
<td class="text-end pe-4">
|
<td class="text-end pe-4">
|
||||||
<a href="{% url 'job_detail' job.pk %}" class="btn btn-sm btn-outline-secondary">View</a>
|
<div class="btn-group">
|
||||||
<a href="{% url 'job_update' job.pk %}" class="btn btn-sm btn-outline-primary">Edit</a>
|
<a href="{% url 'job_detail' job.pk %}" class="btn btn-sm btn-outline-secondary">View</a>
|
||||||
|
<a href="{% url 'job_update' job.pk %}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center py-5">
|
<td colspan="{% if company.is_uprn_required %}6{% else %}5{% endif %}" class="text-center py-5">
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<i class="bi bi-tools display-4 text-muted mb-3 d-block"></i>
|
<div class="bg-light rounded-circle d-inline-flex p-4 mb-3">
|
||||||
<p class="text-muted">No jobs found. Start by creating your first repair job.</p>
|
<i class="bi bi-tools fs-1 text-muted"></i>
|
||||||
<a href="{% url 'job_create' %}" class="btn btn-primary btn-sm mt-2">Create Job</a>
|
</div>
|
||||||
|
<p class="text-muted">No jobs found. Start by creating your first repair job or import them from Excel.</p>
|
||||||
|
<div class="d-flex justify-content-center gap-2 mt-3">
|
||||||
|
<a href="{% url 'job_create' %}" class="btn btn-primary btn-sm">Create Job</a>
|
||||||
|
<a href="{% url 'job_import' %}" class="btn btn-outline-secondary btn-sm">Import Excel</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
43
core/urls.py
43
core/urls.py
@ -2,26 +2,31 @@ from django.urls import path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.home, name="home"),
|
path('', views.home, name='home'),
|
||||||
path("register/", views.register_view, name="register"),
|
path('register/', views.register_view, name='register'),
|
||||||
path("login/", views.login_view, name="login"),
|
path('login/', views.login_view, name='login'),
|
||||||
path("logout/", views.logout_view, name="logout"),
|
path('logout/', views.logout_view, name='logout'),
|
||||||
path("setup/", views.company_setup, name="company_setup"),
|
|
||||||
path("dashboard/", views.dashboard, name="dashboard"),
|
|
||||||
|
|
||||||
# Job CRUD
|
path('company-setup/', views.company_setup, name='company_setup'),
|
||||||
path("jobs/", views.job_list, name="job_list"),
|
path('dashboard/', views.dashboard, name='dashboard'),
|
||||||
path("jobs/create/", views.job_create, name="job_create"),
|
|
||||||
path("jobs/<int:pk>/", views.job_detail, name="job_detail"),
|
# Jobs
|
||||||
path("jobs/<int:pk>/edit/", views.job_update, name="job_update"),
|
path('jobs/', views.job_list, name='job_list'),
|
||||||
path("jobs/<int:pk>/delete/", views.job_delete, name="job_delete"),
|
path('jobs/create/', views.job_create, name='job_create'),
|
||||||
path("jobs/<int:pk>/toggle-folder/<int:folder_id>/", views.toggle_folder_completion, name="toggle_folder_completion"),
|
path('jobs/<int:pk>/', views.job_detail, name='job_detail'),
|
||||||
|
path('jobs/<int:pk>/edit/', views.job_update, name='job_update'),
|
||||||
|
path('jobs/<int:pk>/delete/', views.job_delete, name='job_delete'),
|
||||||
|
path('jobs/<int:pk>/toggle-folder/<int:folder_id>/', views.toggle_folder_completion, name='toggle_folder_completion'),
|
||||||
|
path('jobs/<int:pk>/upload-file/<int:folder_id>/', views.job_upload_file, name='job_upload_file'),
|
||||||
|
path('jobs/<int:pk>/delete-file/<int:file_id>/', views.job_delete_file, name='job_delete_file'),
|
||||||
|
path('jobs/export/', views.job_export, name='job_export'),
|
||||||
|
path('jobs/import/', views.job_import, name='job_import'),
|
||||||
|
|
||||||
# Settings
|
# Settings
|
||||||
path("settings/", views.settings_view, name="settings"),
|
path('settings/', views.settings_view, name='settings'),
|
||||||
path("settings/status/create/", views.status_create, name="status_create"),
|
path('settings/status/create/', views.status_create, name='status_create'),
|
||||||
path("settings/status/<int:pk>/edit/", views.status_update, name="status_update"),
|
path('settings/status/<int:pk>/edit/', views.status_update, name='status_update'),
|
||||||
path("settings/status/<int:pk>/delete/", views.status_delete, name="status_delete"),
|
path('settings/status/<int:pk>/delete/', views.status_delete, name='status_delete'),
|
||||||
path("settings/folder/create/", views.folder_create, name="folder_create"),
|
path('settings/folder/create/', views.folder_create, name='folder_create'),
|
||||||
path("settings/folder/<int:pk>/delete/", views.folder_delete, name="folder_delete"),
|
path('settings/folder/<int:pk>/delete/', views.folder_delete, name='folder_delete'),
|
||||||
]
|
]
|
||||||
163
core/views.py
163
core/views.py
@ -1,13 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
import platform
|
import io
|
||||||
|
import pandas as pd
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
from django.contrib.auth import login, logout, authenticate
|
from django.contrib.auth import login, logout, authenticate
|
||||||
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion
|
from django.http import HttpResponse
|
||||||
from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm
|
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile
|
||||||
|
from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm, JobFileForm, ImportJobsForm
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
@ -46,7 +48,6 @@ def logout_view(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def company_setup(request):
|
def company_setup(request):
|
||||||
# Check if user already has a company
|
|
||||||
if request.user.profile.company:
|
if request.user.profile.company:
|
||||||
return redirect('dashboard')
|
return redirect('dashboard')
|
||||||
|
|
||||||
@ -59,14 +60,11 @@ def company_setup(request):
|
|||||||
if company_form.is_valid() and status_names and default_status_idx is not None:
|
if company_form.is_valid() and status_names and default_status_idx is not None:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
company = company_form.save()
|
company = company_form.save()
|
||||||
|
|
||||||
# Link user to company as ADMIN
|
|
||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
profile.company = company
|
profile.company = company
|
||||||
profile.role = 'ADMIN'
|
profile.role = 'ADMIN'
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
# Create statuses
|
|
||||||
for i, name in enumerate(status_names):
|
for i, name in enumerate(status_names):
|
||||||
if name.strip():
|
if name.strip():
|
||||||
JobStatus.objects.create(
|
JobStatus.objects.create(
|
||||||
@ -76,7 +74,6 @@ def company_setup(request):
|
|||||||
order=i
|
order=i
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create folders
|
|
||||||
for name in folder_names:
|
for name in folder_names:
|
||||||
if name.strip():
|
if name.strip():
|
||||||
RequiredFolder.objects.create(
|
RequiredFolder.objects.create(
|
||||||
@ -110,7 +107,7 @@ def dashboard(request):
|
|||||||
context = {
|
context = {
|
||||||
'company': company,
|
'company': company,
|
||||||
'total_jobs': jobs.count(),
|
'total_jobs': jobs.count(),
|
||||||
'jobs': jobs.order_by('-created_at')[:5], # Recent jobs
|
'jobs': jobs.order_by('-created_at')[:5],
|
||||||
}
|
}
|
||||||
return render(request, 'core/dashboard.html', context)
|
return render(request, 'core/dashboard.html', context)
|
||||||
|
|
||||||
@ -143,7 +140,6 @@ def job_create(request):
|
|||||||
job.company = company
|
job.company = company
|
||||||
job.save()
|
job.save()
|
||||||
|
|
||||||
# Initialize folder completions
|
|
||||||
for folder in company.required_folders.all():
|
for folder in company.required_folders.all():
|
||||||
JobFolderCompletion.objects.get_or_create(job=job, folder=folder)
|
JobFolderCompletion.objects.get_or_create(job=job, folder=folder)
|
||||||
|
|
||||||
@ -167,16 +163,22 @@ def job_detail(request, pk):
|
|||||||
company = profile.company
|
company = profile.company
|
||||||
job = get_object_or_404(Job, pk=pk, company=company)
|
job = get_object_or_404(Job, pk=pk, company=company)
|
||||||
|
|
||||||
# Ensure all required folders have completion records (in case new ones were added later)
|
|
||||||
for folder in company.required_folders.all():
|
for folder in company.required_folders.all():
|
||||||
JobFolderCompletion.objects.get_or_create(job=job, folder=folder)
|
JobFolderCompletion.objects.get_or_create(job=job, folder=folder)
|
||||||
|
|
||||||
folder_completions = job.folder_completions.all().select_related('folder')
|
folder_completions = job.folder_completions.all().select_related('folder')
|
||||||
|
|
||||||
|
# Get files for each folder
|
||||||
|
for completion in folder_completions:
|
||||||
|
completion.files_list = job.files.filter(folder=completion.folder)
|
||||||
|
|
||||||
|
file_form = JobFileForm()
|
||||||
|
|
||||||
return render(request, 'core/job_detail.html', {
|
return render(request, 'core/job_detail.html', {
|
||||||
'job': job,
|
'job': job,
|
||||||
'folder_completions': folder_completions,
|
'folder_completions': folder_completions,
|
||||||
'company': company
|
'company': company,
|
||||||
|
'file_form': file_form
|
||||||
})
|
})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -238,6 +240,141 @@ def toggle_folder_completion(request, pk, folder_id):
|
|||||||
messages.success(request, f"Folder '{completion.folder.name}' status updated.")
|
messages.success(request, f"Folder '{completion.folder.name}' status updated.")
|
||||||
return redirect('job_detail', pk=job.pk)
|
return redirect('job_detail', pk=job.pk)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def job_upload_file(request, pk, folder_id):
|
||||||
|
profile = request.user.profile
|
||||||
|
company = profile.company
|
||||||
|
job = get_object_or_404(Job, pk=pk, company=company)
|
||||||
|
folder = get_object_or_404(RequiredFolder, pk=folder_id, company=company)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = JobFileForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
job_file = form.save(commit=False)
|
||||||
|
job_file.job = job
|
||||||
|
job_file.folder = folder
|
||||||
|
job_file.uploaded_by = request.user
|
||||||
|
job_file.save()
|
||||||
|
messages.success(request, f"File uploaded to {folder.name}.")
|
||||||
|
else:
|
||||||
|
messages.error(request, "Error uploading file.")
|
||||||
|
|
||||||
|
return redirect('job_detail', pk=job.pk)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def job_delete_file(request, pk, file_id):
|
||||||
|
profile = request.user.profile
|
||||||
|
company = profile.company
|
||||||
|
job = get_object_or_404(Job, pk=pk, company=company)
|
||||||
|
job_file = get_object_or_404(JobFile, pk=file_id, job=job)
|
||||||
|
|
||||||
|
job_file.file.delete()
|
||||||
|
job_file.delete()
|
||||||
|
messages.success(request, "File deleted.")
|
||||||
|
return redirect('job_detail', pk=job.pk)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def job_export(request):
|
||||||
|
profile = request.user.profile
|
||||||
|
company = profile.company
|
||||||
|
jobs = Job.objects.filter(company=company)
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for job in jobs:
|
||||||
|
data.append({
|
||||||
|
'Job Ref': job.job_ref,
|
||||||
|
'UPRN': job.uprn,
|
||||||
|
'Address 1': job.address_line_1,
|
||||||
|
'Address 2': job.address_line_2,
|
||||||
|
'Address 3': job.address_line_3,
|
||||||
|
'Postcode': job.postcode,
|
||||||
|
'Status': job.status.name,
|
||||||
|
'Description': job.description,
|
||||||
|
'Created At': job.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
|
||||||
|
# Export as Excel
|
||||||
|
output = io.BytesIO()
|
||||||
|
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Jobs')
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
response = HttpResponse(output, content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="jobs_export_{company.name}.xlsx"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def job_import(request):
|
||||||
|
profile = request.user.profile
|
||||||
|
company = profile.company
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ImportJobsForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
file = request.FILES['file']
|
||||||
|
try:
|
||||||
|
if file.name.endswith('.csv'):
|
||||||
|
df = pd.read_csv(file)
|
||||||
|
else:
|
||||||
|
df = pd.read_excel(file)
|
||||||
|
|
||||||
|
# Basic mapping: Job Ref, UPRN, Address 1, Postcode, Status
|
||||||
|
# Expecting columns to match roughly or using a simple mapping
|
||||||
|
|
||||||
|
starting_status = company.statuses.filter(is_starting_status=True).first()
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
job_ref = str(row.get('Job Ref', '')).strip()
|
||||||
|
if not job_ref: continue
|
||||||
|
|
||||||
|
uprn = str(row.get('UPRN', '')).strip() if pd.notna(row.get('UPRN')) else None
|
||||||
|
addr1 = str(row.get('Address 1', '')).strip()
|
||||||
|
postcode = str(row.get('Postcode', '')).strip()
|
||||||
|
|
||||||
|
# Find status or use default
|
||||||
|
status_name = str(row.get('Status', '')).strip()
|
||||||
|
status = company.statuses.filter(name__iexact=status_name).first() or starting_status
|
||||||
|
|
||||||
|
try:
|
||||||
|
job, created = Job.objects.update_or_create(
|
||||||
|
company=company,
|
||||||
|
job_ref=job_ref,
|
||||||
|
defaults={
|
||||||
|
'uprn': uprn,
|
||||||
|
'address_line_1': addr1,
|
||||||
|
'postcode': postcode,
|
||||||
|
'status': status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize folders
|
||||||
|
for folder in company.required_folders.all():
|
||||||
|
JobFolderCompletion.objects.get_or_create(job=job, folder=folder)
|
||||||
|
|
||||||
|
imported_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Row {index+2}: {str(e)}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
messages.warning(request, f"Imported {imported_count} jobs with some errors: {', '.join(errors[:5])}")
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Successfully imported {imported_count} jobs.")
|
||||||
|
|
||||||
|
return redirect('job_list')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error processing file: {str(e)}")
|
||||||
|
else:
|
||||||
|
form = ImportJobsForm()
|
||||||
|
|
||||||
|
return render(request, 'core/job_import.html', {'form': form, 'company': company})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def settings_view(request):
|
def settings_view(request):
|
||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
@ -269,7 +406,6 @@ def status_create(request):
|
|||||||
status = form.save(commit=False)
|
status = form.save(commit=False)
|
||||||
status.company = profile.company
|
status.company = profile.company
|
||||||
if status.is_starting_status:
|
if status.is_starting_status:
|
||||||
# Unset other starting statuses
|
|
||||||
JobStatus.objects.filter(company=profile.company).update(is_starting_status=False)
|
JobStatus.objects.filter(company=profile.company).update(is_starting_status=False)
|
||||||
status.save()
|
status.save()
|
||||||
messages.success(request, "New status created.")
|
messages.success(request, "New status created.")
|
||||||
@ -329,7 +465,6 @@ def folder_create(request):
|
|||||||
folder = form.save(commit=False)
|
folder = form.save(commit=False)
|
||||||
folder.company = profile.company
|
folder.company = profile.company
|
||||||
folder.save()
|
folder.save()
|
||||||
# Note: The jobs will automatically see this new folder in job_detail view
|
|
||||||
messages.success(request, f"Folder '{folder.name}' added company-wide.")
|
messages.success(request, f"Folder '{folder.name}' added company-wide.")
|
||||||
return redirect('settings')
|
return redirect('settings')
|
||||||
else:
|
else:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user