This commit is contained in:
Peter 2023-09-17 16:54:34 +08:00
commit a2a3870369
37 changed files with 1403 additions and 0 deletions

164
.gitignore vendored Normal file
View File

@ -0,0 +1,164 @@
etc/
test.py
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

3
admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
admin.site.site_header = "Your Custom Admin Title"

0
budget/__init__.py Normal file
View File

16
budget/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for budget project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budget.settings')
application = get_asgi_application()

141
budget/settings.py Normal file
View File

@ -0,0 +1,141 @@
"""
Django settings for budget project.
Generated by 'django-admin startproject' using Django 4.2.5.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from import_export.formats.base_formats import CSV, XLSX
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-=-nu_j+*8_eem=++@3@jw^5nz4!s0%-$y_#p)!wwojce4^*b@!"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"admin_tools",
"admin_tools.theming",
"admin_tools.menu",
"admin_tools.dashboard",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"djmoney",
"expenses",
"summary",
"simple_history",
"import_export",
"admincharts",
"djmoney.contrib.exchange",
]
IMPORT_FORMATS = [CSV, XLSX]
EXPORT_FORMATS = [CSV, XLSX]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"simple_history.middleware.HistoryRequestMiddleware",
]
ROOT_URLCONF = "budget.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "budget.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Australia/West"
USE_TZ = True
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

29
budget/urls.py Normal file
View File

@ -0,0 +1,29 @@
"""
URL configuration for budget project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/expenses/", include("expenses.admin_urls")),
path("admin/summary/", include("summary.urls")),
path("admin/", admin.site.urls),
]
admin.site.site_header = "Better Admin"
admin.site.index_title = "Better Admin"
admin.site.site_title = ""

16
budget/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for budget project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budget.settings')
application = get_wsgi_application()

0
expenses/__init__.py Normal file
View File

165
expenses/admin.py Normal file
View File

@ -0,0 +1,165 @@
from datetime import datetime
from typing import Any, Dict
from django.contrib import admin
from django.contrib.admin.options import ModelAdmin
from django.core.handlers.wsgi import WSGIRequest
from django.db.models.base import Model
from django.db.models.fields import Field
from django.forms import DecimalField, TextInput, NumberInput
from django.db import models
from util import next_payday
from .admin_base import AdminBase, DeletedListFilter, DeletableAdminForm
from .models import Expense, Timesheet, TimesheetRate
from django import forms
from django.utils import timezone
from django.contrib.admin.widgets import AdminDateWidget, AdminSplitDateTime
from import_export import resources
from import_export.admin import ImportExportModelAdmin
from admincharts.admin import AdminChartMixin
from admincharts.utils import months_between_dates
from djmoney.contrib.exchange.models import convert_money
class TimesheetRateAdminForm(DeletableAdminForm):
class Meta:
model = TimesheetRate
fields = "__all__"
@admin.register(TimesheetRate)
class TimesheetRateAdmin(AdminBase):
list_display = (
"rate",
"description",
"default_rate",
)
form = TimesheetRateAdminForm
class TimesheetResource(resources.ModelResource):
class Meta:
model = Timesheet
class TimesheetAdminForm(DeletableAdminForm):
class Meta:
model = Timesheet
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.instance.shift_start:
self.initial["shift_start"] = timezone.now()
self.fields["rate"].initial = forms.ModelChoiceField(
queryset=TimesheetRate.objects.all(),
required=True,
widget=forms.Select,
label="Select Another Table",
)
self.initial["rate"] = TimesheetRate.objects.filter(default_rate=True).first()
@admin.register(Timesheet)
class TimesheetAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin):
resource_classes = [TimesheetResource]
form = TimesheetAdminForm
fields = (
("shift_start", "shift_end"),
("break_start", "break_end"),
("rate"),
("submitted"),
)
list_display = (
"shift_start",
"worked_hours",
"worked_decimal",
"total",
"_rules",
"submitted",
"rate",
# "shift_hours",
# "break_hours",
)
list_filter = ("shift_start", ("deleted", DeletedListFilter))
ordering = ("-shift_start", "-deleted")
readonly_fields = ("deleted",)
def submit(self, request, queryset):
if queryset.exists():
for item in queryset:
if not item.submitted:
item.submitted = timezone.now()
item.save()
def changelist_view(self, request, extra_context=None):
payday = next_payday()
extra_context = {
"title": f"Next timesheet due {payday.strftime('%B %-d')} (in {(payday - datetime.now()).days + 1} days)."
}
return super().changelist_view(request, extra_context=extra_context)
submit.short_description = "Mark as submitted"
actions = [submit, AdminBase.delete_selected, AdminBase.restore_deleted]
def get_list_chart_data(self, queryset):
if not queryset:
return {}
# Cannot reorder the queryset at this point
earliest = min([x.shift_start for x in queryset])
labels = []
totals = []
for b in months_between_dates(earliest, timezone.now()):
labels.append(b.strftime("%b %Y"))
totals.append(
sum(
[
convert_money(x.total, "AUD").amount
for x in queryset
if x.shift_start.year == b.year
and x.shift_start.month == b.month
]
)
)
return {
"labels": labels,
"datasets": [
{
"label": "Income (Pre-tax)",
"data": totals,
"backgroundColor": "#79aec8",
},
],
}
class ExpenseAdminForm(DeletableAdminForm):
class Meta:
model = Expense
fields = "__all__"
@admin.register(Expense)
class ExpenseAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin):
form = ExpenseAdminForm
list_display = ("date", "price", "description")
list_filter = ("date", ("deleted", DeletedListFilter))
ordering = ("-date", "-deleted")
readonly_fields = ("deleted",)
def get_changeform_initial_data(self, request):
initial = super().get_changeform_initial_data(request)
initial["date"] = timezone.now().date()
return initial
list_chart_type = "bar"
list_chart_data = {}
list_chart_options = {"aspectRatio": 6}
list_chart_config = None # Override the combined settings

103
expenses/admin_base.py Normal file
View File

@ -0,0 +1,103 @@
from simple_history.admin import SimpleHistoryAdmin
from .deletable_model import DeletableModel
from django.utils.html import format_html
from django.contrib import admin
from django import forms
class DeletedListFilter(admin.BooleanFieldListFilter):
default = "0"
def __init__(self, field, request, params, model, model_admin, field_path):
super().__init__(field, request, params, model, model_admin, field_path)
if not self.lookup_val:
self.lookup_val = self.default
self.used_parameters[self.lookup_kwarg] = self.default
def choices(self, changelist):
choices = super().choices(changelist)
choices.__next__()
for choice in choices:
yield choice
class DeletableAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.deleted:
# If the object is marked as deleted, add your custom action button
self.fields["restore_deleted"] = forms.BooleanField(
widget=forms.CheckboxInput(attrs={"class": "hidden"}),
required=False,
initial=True,
label="Restore This Item",
)
class AdminBase(SimpleHistoryAdmin):
readonly_fields = ("deleted",)
# Override the delete_view method to suppress the confirmation page
def delete_view(self, request, object_id, extra_context=None):
obj: DeletableModel = self.get_object(request, object_id)
if obj:
obj.mark_deleted()
obj.save()
return self.response_delete(request, obj_display=str(obj), obj_id=obj.pk)
# Override delete button
def delete_model(self, request, obj: DeletableModel):
if obj:
obj.mark_deleted()
obj.save()
def delete_selected(modeladmin, request, queryset):
# Show confirmation page.
for obj in queryset:
if obj:
obj.mark_deleted()
obj.save()
def restore_deleted(self, request, queryset):
# Check if the selected items are deleted
deleted_items = queryset.filter(deleted=True)
# Perform the action only if there are deleted items
if deleted_items.exists():
# Your custom logic here
for item in deleted_items:
item.deleted = False
item.save()
restore_deleted.short_description = "Restore Selected Items" # Action button text
# Add your custom action to the actions list
actions = [delete_selected, restore_deleted]
"""
HISTORY
https://stackoverflow.com/a/72187314
"""
history_list_display = ["list_changes"]
def changed_fields(self, obj):
if obj.prev_record:
delta = obj.diff_against(obj.prev_record)
return delta.changed_fields
return None
def list_changes(self, obj):
fields = ""
if obj.prev_record:
delta = obj.diff_against(obj.prev_record)
for change in delta.changes:
fields += str(
(
f"<strong>{change.field}</strong> "
f"changed from <span style='background-color:#ffb5ad'>{change.old}</span> "
f"to <span style='background-color:#b3f7ab'>{change.new}</span> . <br/>"
)
)
return format_html(fields)
return None

12
expenses/admin_urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = "expenses"
urlpatterns = [
path(
"expense/<int:item_id>/restore/",
views.restore_item,
name="expenses_restore_item",
),
]

6
expenses/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExpensesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "expenses"

View File

@ -0,0 +1,13 @@
from django.db import models
from simple_history.models import HistoricalRecords
class DeletableModel(models.Model):
class Meta:
abstract = True
history = HistoricalRecords(inherit=True)
deleted = models.BooleanField(default=False)
def mark_deleted(self):
self.deleted = True

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,62 @@
# Generated by Django 4.2.5 on 2023-09-16 21:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import simple_history.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('expenses', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ExpenseCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('deleted', models.BooleanField(default=False)),
('name', models.TextField(blank=True)),
('description', models.TextField(blank=True)),
('default_rate', models.BooleanField(default=False)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='HistoricalExpenseCategory',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('deleted', models.BooleanField(default=False)),
('name', models.TextField(blank=True)),
('description', models.TextField(blank=True)),
('default_rate', models.BooleanField(default=False)),
('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)),
],
options={
'verbose_name': 'historical expense category',
'verbose_name_plural': 'historical expense categorys',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.AddField(
model_name='expense',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to='expenses.expensecategory'),
),
migrations.AddField(
model_name='historicalexpense',
name='category',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='expenses.expensecategory'),
),
]

View File

133
expenses/models.py Normal file
View File

@ -0,0 +1,133 @@
from datetime import datetime, timedelta
from django.db import models
from django.utils import timezone
from djmoney.models.fields import MoneyField, Money
from .deletable_model import DeletableModel
from util import truncate_string # new line
from django.db.models import Q
# class ExpenseManager(models.Manager):
# def active(self):
# return self.filter(deleted=False)
# # Soft delete only!
# def delete_model(self, request, obj):
# obj.deleted = True
# obj.save()
# def get_actions(self, request):
# actions = super().get_actions(request)
# # Remove the default delete action from the actions list
# del actions["delete_selected"]
# return actions
# def soft_delete_selected(self, request, queryset):
# # Mark selected entries as deleted (soft delete)
# queryset.update(deleted=True)
# soft_delete_selected.short_description = "Soft delete selected entries"
# actions = [soft_delete_selected]
class TimesheetRate(DeletableModel):
rate = MoneyField(
decimal_places=3,
default=0.000,
default_currency="AUD",
max_digits=11,
)
description = models.TextField(blank=True)
default_rate = models.BooleanField(default=False)
def __str__(self):
return f"{self.rate} {truncate_string(self.description,8)}"
def save(self, *args, **kwargs):
# Ensure that only one entry can be marked as a favorite
if self.default_rate:
TimesheetRate.objects.filter(~Q(pk=self.pk)).update(default_rate=False)
super().save(*args, **kwargs)
class Timesheet(DeletableModel):
shift_start = models.DateTimeField(null=True, blank=True)
shift_end = models.DateTimeField(null=True, blank=True)
break_start = models.DateTimeField(null=True, blank=True)
break_end = models.DateTimeField(null=True, blank=True)
submitted = models.DateTimeField(null=True, blank=True)
rate = models.ForeignKey(
TimesheetRate,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="timesheets",
)
@property
def shift_hours(self) -> timedelta:
if self.shift_start and self.shift_end:
return self.shift_end - self.shift_start
return timedelta(0)
@property
def break_hours(self) -> timedelta:
if self.break_start and self.break_end:
return self.break_end - self.break_start
return timedelta(0)
@property
def worked_hours(self) -> timedelta:
return self.shift_hours - self.break_hours
@property
def worked_decimal(self) -> str:
return f"{self.worked_hours.total_seconds() / 60.0 / 60:.4f}"
@property
def total(self) -> Money:
hours = self.worked_hours.total_seconds() / 60.0 / 60
return hours * self.rate.rate
def _rules(self):
hours = self.worked_hours.total_seconds() / 60.0 / 60
return hours > 3 and hours <= 7.5
_rules.boolean = True
rules = property(_rules)
def __str__(self):
return f"{self.shift_start} to {self.shift_end}"
class ExpenseCategory(DeletableModel):
name = models.TextField(blank=True)
description = models.TextField(blank=True)
default_rate = models.BooleanField(default=False)
def __str__(self):
return self.name
class Expense(DeletableModel):
price = MoneyField(
decimal_places=3,
default=0.000,
default_currency="AUD",
max_digits=11,
)
date = models.DateField()
description = models.TextField(blank=True)
merchant = models.CharField(max_length=255, blank=True)
receipt = models.FileField(upload_to="receipts/", blank=True)
link = models.URLField(blank=True)
category = models.ForeignKey(
ExpenseCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="expenses",
)
def __str__(self):
return f"{self.date} {self.price} {truncate_string(self.description,32)}"

View File

@ -0,0 +1,9 @@
{% extends "admin/change_list.html" %}
{% block change_list_title %}
<h1>Custom Title for Expense List</h1>
{% endblock %}
{% block content %}
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "admin/change_form.html" %} {% load i18n admin_urls static
admin_modify %} {% load static %} {% block extrahead %} {{ block.super }}
<script
type="text/javascript"
src="{% static 'custom_datetime_widget.js' %}"
></script>
<script>
// MAKE DELETED FORMS READONLY
document.addEventListener("DOMContentLoaded", function () {
setDeletedReadOnly();
});
const setDeletedReadOnly = () => {
const deleted = {{ original.deleted|yesno:"true,false" }};
const form = document.querySelector("form");
if (deleted) {
const formElements = document.querySelectorAll("input, textarea, select");
formElements.forEach((element) => {
element.disabled = true;
element.style.cursor = "not-allowed";
});
}
};
</script>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% load i18n admin_urls %}
<div class="submit-row">
{% block submit-row %}
{% if original and original.deleted %}
<a href="{% url 'expenses:expenses_restore_item' original.pk %}" class="deletelink">{% translate "Restore" %}</a> <!-- Updated URL name -->
{% else %}
{% if show_save %}<input type="submit" value="{% translate 'Save' %}" class="default" name="_save">{% endif %}
{% if show_save_as_new %}<input type="submit" value="{% translate 'Save as new' %}" name="_saveasnew">{% endif %}
{% if show_save_and_add_another %}<input type="submit" value="{% translate 'Save and add another' %}" name="_addanother">{% endif %}
{% if show_save_and_continue %}<input type="submit" value="{% if can_change %}{% translate 'Save and continue editing' %}{% else %}{% translate 'Save and view' %}{% endif %}" name="_continue">{% endif %}
{% if show_close %}
{% url opts|admin_urlname:'changelist' as changelist_url %}
<a href="{% add_preserved_filters changelist_url %}" class="closelink">{% translate 'Close' %}</a>
{% endif %}
{% if show_delete_link and original and not original.deleted %}
{% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %}
<a href="{% add_preserved_filters delete_url %}" class="deletelink">{% translate "Delete" %}</a>
{% endif %}
{% endif %}
{% endblock %}
</div>

3
expenses/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

17
expenses/views.py Normal file
View File

@ -0,0 +1,17 @@
from django.shortcuts import render
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.contrib import messages
from .models import Expense
def restore_item(request, item_id):
try:
item = Expense.objects.get(pk=item_id)
item.deleted = False
item.save()
messages.success(request, "Item restored successfully.")
except Expense.DoesNotExist:
messages.error(request, "Item not found.")
return HttpResponseRedirect(reverse("admin:expenses_expense_changelist"))

22
manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budget.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

21
requirements.txt Normal file
View File

@ -0,0 +1,21 @@
asgiref==3.7.2
Babel==2.12.1
backports.zoneinfo==0.2.1
defusedxml==0.7.1
diff-match-patch==20230430
Django==4.2.5
django-import-export==3.3.1
django-money==3.2.0
django-simple-history==3.4.0
et-xmlfile==1.1.0
MarkupPy==1.14
odfpy==1.4.1
openpyxl==3.1.2
py-moneyed==3.0
pytz==2023.3.post1
PyYAML==6.0.1
sqlparse==0.4.4
tablib==3.5.0
typing-extensions==4.7.1
xlrd==2.0.1
xlwt==1.3.0

0
summary/__init__.py Normal file
View File

54
summary/admin.py Normal file
View File

@ -0,0 +1,54 @@
from django.contrib import admin
from .models import SaleSummary
from django.db.models import (
Sum,
Count,
F,
ExpressionWrapper,
DecimalField,
DurationField,
FloatField,
)
from djmoney.models.fields import MoneyField
from django.db.models.functions import TruncMonth
@admin.register(SaleSummary)
class SaleSummaryAdmin(admin.ModelAdmin):
change_list_template = "admin/sale_summary_change_list.html"
date_hierarchy = "shift_start"
def changelist_view(self, request, extra_context=None):
response = super().changelist_view(
request,
extra_context=extra_context,
)
try:
qs = response.context_data["cl"].queryset
except (AttributeError, KeyError):
return response
metrics = {
"month": TruncMonth("shift_start"),
"total": Sum(F("shift_end") - F("shift_start")),
"total_sales": Sum(
ExpressionWrapper(
(F("shift_end") - F("shift_start"))
/ 60.0
/ 1000000
/ 60
* F("rate__rate"),
# MoneyField does not work https://github.com/django-money/django-money/issues/627
output_field=DecimalField(),
)
),
}
response.context_data["summary"] = list(
qs.values("shift_start__month").annotate(**metrics).order_by("month")
)
metrics.pop("month")
response.context_data["summary_total"] = dict(qs.aggregate(**metrics))
return response

6
summary/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class SummaryConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'summary'

View File

@ -0,0 +1,57 @@
# Generated by Django 4.2.5 on 2023-09-16 21:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import simple_history.models
class Migration(migrations.Migration):
initial = True
dependencies = [
('expenses', '0002_expensecategory_historicalexpensecategory_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SaleSummary',
fields=[
],
options={
'verbose_name': 'Sale Summary',
'verbose_name_plural': 'Sales Summary',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('expenses.timesheet',),
),
migrations.CreateModel(
name='HistoricalSaleSummary',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('deleted', models.BooleanField(default=False)),
('shift_start', models.DateTimeField(blank=True, null=True)),
('shift_end', models.DateTimeField(blank=True, null=True)),
('break_start', models.DateTimeField(blank=True, null=True)),
('break_end', models.DateTimeField(blank=True, null=True)),
('submitted', models.DateTimeField(blank=True, null=True)),
('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)),
('rate', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='expenses.timesheetrate')),
],
options={
'verbose_name': 'historical Sale Summary',
'verbose_name_plural': 'historical Sales Summary',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

9
summary/models.py Normal file
View File

@ -0,0 +1,9 @@
from django.db import models
from expenses.models import Timesheet
class SaleSummary(Timesheet):
class Meta:
proxy = True
verbose_name = "Sale Summary"
verbose_name_plural = "Sales Summary"

View File

@ -0,0 +1,62 @@
<!-- sales/templates/admin/sale_summary_change_list.html -->
{% extends "admin/change_list.html" %} {% block content_title %}{% load humanize
%}{% load month_name %}
<h1>Sales Summary</h1>
{% endblock %} {% block result_list %}
<div class="results">
<table>
<thead>
<tr>
<th>
<div class="text">
<a href="#">Category</a>
</div>
</th>
<th>
<div class="text">
<a href="#">Total</a>
</div>
</th>
<th>
<div class="text">
<a href="#">Total Sales</a>
</div>
</th>
<th>
<div class="text">
<a href="#">
<strong>% Of Total Sales</strong>
</a>
</div>
</th>
</tr>
</thead>
<tbody>
{% for row in summary %}
<tr class="{% cycle 'row1' 'row2' %}">
<td>{{ row.month|date:"M Y" }}</td>
<td>{{ row.total }}</td>
<td>${{ row.total_sales|floatformat:3 }}</td>
<td>
<strong>
{{ row.total_sales | default:0 | percentof:summary_total.total_sales
}}
</strong>
</td>
</tr>
{% endfor %}
</tbody>
<tr style="font-weight: bold; border-top: 2px solid #dddddd">
<td>Total</td>
<td>{{ summary_total.total }}</td>
<td>${{ summary_total.total_sales|floatformat:3}}</td>
<td>100%</td>
</tr>
</table>
</div>
{% endblock %} {% block pagination %}{% endblock %}

View File

View File

@ -0,0 +1,13 @@
from django import template
import calendar
register = template.Library()
@register.filter
def month_name(month_number):
month_number = int(month_number)
return calendar.month_name[month_number]
register.filter("month_name", month_name)

3
summary/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

6
summary/urls.py Normal file
View File

@ -0,0 +1,6 @@
from django.urls import path
from . import admin
urlpatterns = [
path("summary/", admin.SaleSummaryAdmin, name="summary_dashboard"),
]

32
summary/views.py Normal file
View File

@ -0,0 +1,32 @@
# from django.shortcuts import render
# from django.db.models import Sum, F
# from .models import TimesheetSummary
# from expenses.models import Timesheet # Adjust the import as needed
# def summary_dashboard(request):
# # Calculate summary data (example: total hours and total sales per month)
# summary_data = (
# Timesheet.objects.values("shift_start__month", "shift_start__year")
# .annotate(
# total_hours=Sum(F("shift_end") - F("shift_start")),
# total_sales=Sum("id"),
# )
# .order_by("-shift_start__year", "-shift_start__month")
# )
# # Save or update the summary data in the TimesheetSummary model
# for data in summary_data:
# month = data["shift_start__month"]
# year = data["shift_start__year"]
# total_hours = data["total_hours"]
# total_sales = data["total_sales"]
# # # Use get_or_create to avoid duplicates
# # summary_obj, _ = TimesheetSummary.objects.get_or_create(
# # month=month,
# # year=year,
# # defaults={"total_hours": total_hours, "total_sales": total_sales},
# # )
# context = {"summary_data": TimesheetSummary.objects.all()}
# return render(request, "admin/summary/summary_dashboard.html", context)

35
util.py Normal file
View File

@ -0,0 +1,35 @@
from datetime import datetime, timedelta
def truncate_string(text: str, max_length: int) -> str:
if len(text) <= max_length:
return text
else:
return text[: max_length - 3] + "..."
# Not efficient at all, but I'm done trying to find efficient ways to do this
# Nothing on the internet when I try to search up "Python pay day on monday
# every two weeks" without it turning into something like today -> next
# monday + 14 days
# Either way, it's bounded by 52 weeks so it's actually probably going to be good enough
def next_payday(day=datetime.now()) -> datetime:
first_monday = datetime(day.year, 1, 1)
if first_monday.weekday() != 0:
days_until_next_monday = (7 - first_monday.weekday()) % 7
first_monday += timedelta(days=days_until_next_monday)
first_monday += timedelta(days=7)
next_payday = first_monday
# Calculate the next payday within a 14-day period
while next_payday <= day:
next_payday += timedelta(days=7)
if next_payday.weekday() != 0:
days_until_next_monday = (7 - next_payday.weekday()) % 7
next_payday += timedelta(days=days_until_next_monday - 1)
else:
next_payday += timedelta(days=7)
next_payday -= timedelta(days=1)
return next_payday