diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 96bce55..f256387 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 0b85e94..2da1a1c 100644 Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 291d043..5232038 100644 --- a/config/settings.py +++ b/config/settings.py @@ -155,6 +155,10 @@ STATICFILES_DIRS = [ BASE_DIR / 'node_modules', ] +# Media files (Uploads) +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' + # Email EMAIL_BACKEND = os.getenv( "EMAIL_BACKEND", @@ -179,4 +183,4 @@ if EMAIL_USE_SSL: # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' \ No newline at end of file diff --git a/config/urls.py b/config/urls.py index bcfc074..cccd18f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -27,3 +27,4 @@ urlpatterns = [ if settings.DEBUG: urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 1ba2fec..5e6a7ba 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 4869d19..d8c2e39 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 4ee1212..836029c 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/forms.py b/core/forms.py index f26ed7b..653a78a 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,5 +1,5 @@ from django import forms -from .models import Company, JobStatus, RequiredFolder, Job +from .models import Company, JobStatus, RequiredFolder, Job, JobFile class CompanyForm(forms.ModelForm): class Meta: @@ -65,3 +65,14 @@ class JobForm(forms.ModelForm): if not uprn: return None 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'})) \ No newline at end of file diff --git a/core/templates/core/job_detail.html b/core/templates/core/job_detail.html index bee1474..61fd9eb 100644 --- a/core/templates/core/job_detail.html +++ b/core/templates/core/job_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load static %} {% block title %}Job: {{ job.job_ref }} - RepairsHub{% endblock %} {% block content %}
@@ -81,24 +82,94 @@
{% for completion in folder_completions %} -
-
-
-
-
{{ completion.folder.name }}
-

- 0 Files -

+
+
+
+
+
+
{{ completion.folder.name }}
+

+ {{ completion.files_list.count }} Files uploaded +

+
+
+ +
+ {% csrf_token %} + +
+
-
+ + {% if completion.files_list %} +
+ + + + + + + + + + + {% for job_file in completion.files_list %} + + + + + + + {% endfor %} + +
File NameUploaded ByDateActions
+ + {{ job_file.file.name|cut:"job_files/" }} + + {{ job_file.uploaded_by.username }}{{ job_file.uploaded_at|date:"d/m/y H:i" }} + + + +
+
+ {% else %} +
+

No files uploaded yet.

+
+ {% endif %} +
+
+
+ + +
-
+
-
Quick Export
-

Download this job details for offline reporting.

- +
Quick Export
+

Download this job details for offline reporting.

+ + Export to Excel +
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/job_import.html b/core/templates/core/job_import.html new file mode 100644 index 0000000..7b5ba71 --- /dev/null +++ b/core/templates/core/job_import.html @@ -0,0 +1,72 @@ +{% extends 'base.html' %} +{% block title %}Import Jobs - RepairsHub{% endblock %} +{% block content %} +
+
+
+ + +
+
+

Import Jobs

+

Upload an Excel or CSV file to bulk create jobs.

+
+
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+
Instructions
+
    +
  • Supported formats: .xlsx, .xls, .csv
  • +
  • The following columns are recognized: +
      +
    • Job Ref (Required, Unique)
    • +
    • UPRN (Optional)
    • +
    • Address 1 (Required)
    • +
    • Postcode (Required)
    • +
    • Status (Optional, defaults to starting status)
    • +
    +
  • +
  • Duplicate Job Ref will update existing jobs.
  • +
+
+ +
+ {% csrf_token %} +
+ + {{ form.file }} + {% if form.file.errors %} +
{{ form.file.errors }}
+ {% endif %} +
+ +
+ + Cancel +
+
+
+
+ +
+

Need a template? Export existing jobs to see the required format.

+
+
+
+
+{% endblock %} diff --git a/core/templates/core/job_list.html b/core/templates/core/job_list.html index 7647c69..1eec283 100644 --- a/core/templates/core/job_list.html +++ b/core/templates/core/job_list.html @@ -7,9 +7,20 @@

Repair Jobs

Manage and track all repairs for {{ company.name }}

- - Create New Job - +
+ + + Create New Job + +
{% if messages %} @@ -21,7 +32,7 @@ {% endfor %} {% endif %} -
+
@@ -44,7 +55,7 @@ {% endif %} {% empty %} - @@ -74,4 +92,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index 44a0956..3341915 100644 --- a/core/urls.py +++ b/core/urls.py @@ -2,26 +2,31 @@ from django.urls import path from . import views urlpatterns = [ - path("", views.home, name="home"), - path("register/", views.register_view, name="register"), - path("login/", views.login_view, name="login"), - path("logout/", views.logout_view, name="logout"), - path("setup/", views.company_setup, name="company_setup"), - path("dashboard/", views.dashboard, name="dashboard"), + path('', views.home, name='home'), + path('register/', views.register_view, name='register'), + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), - # Job CRUD - path("jobs/", views.job_list, name="job_list"), - path("jobs/create/", views.job_create, name="job_create"), - path("jobs//", views.job_detail, name="job_detail"), - path("jobs//edit/", views.job_update, name="job_update"), - path("jobs//delete/", views.job_delete, name="job_delete"), - path("jobs//toggle-folder//", views.toggle_folder_completion, name="toggle_folder_completion"), + path('company-setup/', views.company_setup, name='company_setup'), + path('dashboard/', views.dashboard, name='dashboard'), + + # Jobs + path('jobs/', views.job_list, name='job_list'), + path('jobs/create/', views.job_create, name='job_create'), + path('jobs//', views.job_detail, name='job_detail'), + path('jobs//edit/', views.job_update, name='job_update'), + path('jobs//delete/', views.job_delete, name='job_delete'), + path('jobs//toggle-folder//', views.toggle_folder_completion, name='toggle_folder_completion'), + path('jobs//upload-file//', views.job_upload_file, name='job_upload_file'), + path('jobs//delete-file//', 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 - path("settings/", views.settings_view, name="settings"), - path("settings/status/create/", views.status_create, name="status_create"), - path("settings/status//edit/", views.status_update, name="status_update"), - path("settings/status//delete/", views.status_delete, name="status_delete"), - path("settings/folder/create/", views.folder_create, name="folder_create"), - path("settings/folder//delete/", views.folder_delete, name="folder_delete"), -] \ No newline at end of file + path('settings/', views.settings_view, name='settings'), + path('settings/status/create/', views.status_create, name='status_create'), + path('settings/status//edit/', views.status_update, name='status_update'), + path('settings/status//delete/', views.status_delete, name='status_delete'), + path('settings/folder/create/', views.folder_create, name='folder_create'), + path('settings/folder//delete/', views.folder_delete, name='folder_delete'), +] diff --git a/core/views.py b/core/views.py index 1af256f..d383351 100644 --- a/core/views.py +++ b/core/views.py @@ -1,13 +1,15 @@ import os -import platform +import io +import pandas as pd from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth import login, logout, authenticate from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.decorators import login_required from django.contrib import messages from django.db import transaction -from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion -from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm +from django.http import HttpResponse +from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile +from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm, JobFileForm, ImportJobsForm def home(request): if request.user.is_authenticated: @@ -46,7 +48,6 @@ def logout_view(request): @login_required def company_setup(request): - # Check if user already has a company if request.user.profile.company: 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: with transaction.atomic(): company = company_form.save() - - # Link user to company as ADMIN profile = request.user.profile profile.company = company profile.role = 'ADMIN' profile.save() - # Create statuses for i, name in enumerate(status_names): if name.strip(): JobStatus.objects.create( @@ -76,7 +74,6 @@ def company_setup(request): order=i ) - # Create folders for name in folder_names: if name.strip(): RequiredFolder.objects.create( @@ -110,7 +107,7 @@ def dashboard(request): context = { 'company': company, '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) @@ -143,7 +140,6 @@ def job_create(request): job.company = company job.save() - # Initialize folder completions for folder in company.required_folders.all(): JobFolderCompletion.objects.get_or_create(job=job, folder=folder) @@ -167,16 +163,22 @@ def job_detail(request, pk): company = profile.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(): JobFolderCompletion.objects.get_or_create(job=job, folder=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', { 'job': job, 'folder_completions': folder_completions, - 'company': company + 'company': company, + 'file_form': file_form }) @login_required @@ -238,6 +240,141 @@ def toggle_folder_completion(request, pk, folder_id): messages.success(request, f"Folder '{completion.folder.name}' status updated.") 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 def settings_view(request): profile = request.user.profile @@ -269,7 +406,6 @@ def status_create(request): status = form.save(commit=False) status.company = profile.company if status.is_starting_status: - # Unset other starting statuses JobStatus.objects.filter(company=profile.company).update(is_starting_status=False) status.save() messages.success(request, "New status created.") @@ -329,7 +465,6 @@ def folder_create(request): folder = form.save(commit=False) folder.company = profile.company 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.") return redirect('settings') else: @@ -347,4 +482,4 @@ def folder_delete(request, pk): folder.delete() messages.success(request, "Folder removed from company settings.") return redirect('settings') - return render(request, 'core/folder_confirm_delete.html', {'folder': folder}) \ No newline at end of file + return render(request, 'core/folder_confirm_delete.html', {'folder': folder})
{{ job.uprn }} -
{{ job.address_line_1 }}
+
{{ job.address_line_1 }}
{{ job.postcode }}
@@ -54,17 +65,24 @@ {{ job.created_at|date:"M d, Y" }} - View - Edit +
+ View + Edit +
+
- -

No jobs found. Start by creating your first repair job.

- Create Job +
+ +
+

No jobs found. Start by creating your first repair job or import them from Excel.

+