commit 7beb1d9ca6bd03b4d96a6a91ddf12338b2796b18 Author: MS.Kondratev Date: Wed Jul 15 00:12:31 2026 +0500 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ead3da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.venv +__pycache__ +*.pyc +.pytest_cache +db.sqlite3 +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..547b9d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +db.sqlite3 +staticfiles/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34ded17 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.13-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir "Django>=5.2,<5.3" "reportlab>=4.2,<5" +COPY . . +RUN python manage.py collectstatic --noinput +CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab2231f --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Timerr + +Веб-приложение для учёта выполненных работ, времени и стоимости по клиентам и проектам. + +## Локальный запуск + +```powershell +py -3.12 -m venv .venv +.\.venv\Scripts\python.exe -m pip install -e ".[dev]" +.\.venv\Scripts\python.exe manage.py migrate +.\.venv\Scripts\python.exe manage.py createsuperuser +.\.venv\Scripts\python.exe manage.py runserver +``` + +Панель технического администрирования: `http://127.0.0.1:8000/django-admin/`. + +Через неё создаются пользователи, компании, проекты, участники и ставки. Новому пользователю следует задать роль, временный пароль и включить требование смены пароля. + +## Проверка + +```powershell +.\.venv\Scripts\python.exe manage.py check +.\.venv\Scripts\python.exe -m pytest +``` diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000..d6e5851 --- /dev/null +++ b/apps/accounts/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import User + +@admin.register(User) +class TimerrUserAdmin(UserAdmin): + fieldsets = UserAdmin.fieldsets + (("Timerr", {"fields": ("role", "must_change_password")}),) + add_fieldsets = UserAdmin.add_fieldsets + (("Timerr", {"fields": ("role", "first_name", "last_name", "must_change_password")}),) + list_display = ("username", "first_name", "last_name", "role", "is_active") diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..bd9d60a --- /dev/null +++ b/apps/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.accounts' diff --git a/apps/accounts/forms.py b/apps/accounts/forms.py new file mode 100644 index 0000000..228cecb --- /dev/null +++ b/apps/accounts/forms.py @@ -0,0 +1,10 @@ +from django.contrib.auth.forms import PasswordChangeForm + + +class RequiredPasswordChangeForm(PasswordChangeForm): + def save(self, commit=True): + user = super().save(commit=False) + user.must_change_password = False + if commit: + user.save(update_fields=("password", "must_change_password", "updated_at")) + return user diff --git a/apps/accounts/middleware.py b/apps/accounts/middleware.py new file mode 100644 index 0000000..0188645 --- /dev/null +++ b/apps/accounts/middleware.py @@ -0,0 +1,14 @@ +from django.shortcuts import redirect +from django.urls import reverse + + +class PasswordChangeRequiredMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated and request.user.must_change_password: + allowed_paths = {reverse("logout"), reverse("password-change")} + if request.path not in allowed_paths: + return redirect("password-change") + return self.get_response(request) diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..6888534 --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.16 on 2026-07-14 18:48 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('role', models.CharField(choices=[('admin', 'Администратор'), ('worker', 'Исполнитель')], default='worker', max_length=16)), + ('must_change_password', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..2e75242 --- /dev/null +++ b/apps/accounts/models.py @@ -0,0 +1,27 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +class User(AbstractUser): + class Role(models.TextChoices): + ADMIN = "admin", "Администратор" + WORKER = "worker", "Исполнитель" + + role = models.CharField(max_length=16, choices=Role.choices, default=Role.WORKER) + must_change_password = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + @property + def is_app_admin(self): + return self.is_superuser or self.role == self.Role.ADMIN + + def save(self, *args, **kwargs): + if self.role == self.Role.ADMIN: + self.is_staff = True + super().save(*args, **kwargs) + + def has_perm(self, perm, obj=None): + return True if self.is_app_admin else super().has_perm(perm, obj) + + def has_module_perms(self, app_label): + return True if self.is_app_admin else super().has_module_perms(app_label) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..35aafa2 --- /dev/null +++ b/apps/accounts/urls.py @@ -0,0 +1,10 @@ +from django.contrib.auth import views as auth_views +from django.urls import path + +from . import views + +urlpatterns = [ + path("login/", auth_views.LoginView.as_view(template_name="accounts/login.html"), name="login"), + path("logout/", auth_views.LogoutView.as_view(), name="logout"), + path("password/change/", views.password_change, name="password-change"), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py new file mode 100644 index 0000000..bd82e4a --- /dev/null +++ b/apps/accounts/views.py @@ -0,0 +1,15 @@ +from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render + +from .forms import RequiredPasswordChangeForm + + +@login_required +def password_change(request): + form = RequiredPasswordChangeForm(request.user, request.POST or None) + if request.method == "POST" and form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) + return redirect("task-list") + return render(request, "accounts/password_change.html", {"form": form}) diff --git a/apps/companies/__init__.py b/apps/companies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/companies/admin.py b/apps/companies/admin.py new file mode 100644 index 0000000..c69c10a --- /dev/null +++ b/apps/companies/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Company + +admin.site.register(Company) diff --git a/apps/companies/apps.py b/apps/companies/apps.py new file mode 100644 index 0000000..be80909 --- /dev/null +++ b/apps/companies/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CompaniesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.companies' diff --git a/apps/companies/migrations/0001_initial.py b/apps/companies/migrations/0001_initial.py new file mode 100644 index 0000000..9047161 --- /dev/null +++ b/apps/companies/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.16 on 2026-07-14 18:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Company', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Название')), + ('legal_name', models.CharField(blank=True, max_length=255, verbose_name='Юридическое название')), + ('details', models.TextField(blank=True, verbose_name='Реквизиты')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ('name',), + }, + ), + ] diff --git a/apps/companies/migrations/__init__.py b/apps/companies/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/companies/models.py b/apps/companies/models.py new file mode 100644 index 0000000..8a00adc --- /dev/null +++ b/apps/companies/models.py @@ -0,0 +1,16 @@ +from django.db import models + + +class Company(models.Model): + name = models.CharField("Название", max_length=200, unique=True) + legal_name = models.CharField("Юридическое название", max_length=255, blank=True) + details = models.TextField("Реквизиты", blank=True) + is_active = models.BooleanField("Активна", default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name diff --git a/apps/companies/tests.py b/apps/companies/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/companies/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/companies/urls.py b/apps/companies/urls.py new file mode 100644 index 0000000..3f893bc --- /dev/null +++ b/apps/companies/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from . import views + +urlpatterns = [path("companies/", views.company_list, name="company-list")] diff --git a/apps/companies/views.py b/apps/companies/views.py new file mode 100644 index 0000000..0607de1 --- /dev/null +++ b/apps/companies/views.py @@ -0,0 +1,11 @@ +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.shortcuts import render + +from .models import Company + +@login_required +def company_list(request): + if not request.user.is_app_admin: + raise PermissionDenied + return render(request, "management/company_list.html", {"companies": Company.objects.all()}) diff --git a/apps/projects/__init__.py b/apps/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/admin.py b/apps/projects/admin.py new file mode 100644 index 0000000..1ec95ab --- /dev/null +++ b/apps/projects/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from .models import Project, ProjectMember + +class ProjectMemberInline(admin.TabularInline): + model = ProjectMember + extra = 1 + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + list_display = ("name", "company", "is_active") + list_filter = ("company", "is_active") + inlines = (ProjectMemberInline,) + + +@admin.register(ProjectMember) +class ProjectMemberAdmin(admin.ModelAdmin): + list_display = ("user", "project", "hourly_rate", "is_active") + list_filter = ("is_active", "project__company") diff --git a/apps/projects/apps.py b/apps/projects/apps.py new file mode 100644 index 0000000..46a1b48 --- /dev/null +++ b/apps/projects/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.projects' diff --git a/apps/projects/migrations/0001_initial.py b/apps/projects/migrations/0001_initial.py new file mode 100644 index 0000000..24a968d --- /dev/null +++ b/apps/projects/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.16 on 2026-07-14 18:48 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('companies', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='projects', to='companies.company')), + ], + options={ + 'ordering': ('company__name', 'name'), + }, + ), + migrations.CreateModel( + name='ProjectMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hourly_rate', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='Ставка в час')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='projects.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='project_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('project__company__name', 'project__name', 'user__username'), + }, + ), + migrations.AddConstraint( + model_name='project', + constraint=models.UniqueConstraint(fields=('company', 'name'), name='unique_project_name_per_company'), + ), + migrations.AddConstraint( + model_name='projectmember', + constraint=models.UniqueConstraint(fields=('project', 'user'), name='unique_project_member'), + ), + ] diff --git a/apps/projects/migrations/__init__.py b/apps/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/models.py b/apps/projects/models.py new file mode 100644 index 0000000..076590e --- /dev/null +++ b/apps/projects/models.py @@ -0,0 +1,38 @@ +from decimal import Decimal + +from django.conf import settings +from django.core.validators import MinValueValidator +from django.db import models + +from apps.companies.models import Company + +class Project(models.Model): + company = models.ForeignKey(Company, on_delete=models.PROTECT, related_name="projects") + name = models.CharField("Название", max_length=200) + description = models.TextField("Описание", blank=True) + is_active = models.BooleanField("Активен", default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("company__name", "name") + constraints = [models.UniqueConstraint(fields=("company", "name"), name="unique_project_name_per_company")] + + def __str__(self): + return f"{self.company}: {self.name}" + + +class ProjectMember(models.Model): + project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="memberships") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="project_memberships") + hourly_rate = models.DecimalField("Ставка в час", max_digits=12, decimal_places=2, validators=[MinValueValidator(Decimal("0"))]) + is_active = models.BooleanField("Активен", default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("project__company__name", "project__name", "user__username") + constraints = [models.UniqueConstraint(fields=("project", "user"), name="unique_project_member")] + + def __str__(self): + return f"{self.user} — {self.project}" diff --git a/apps/projects/tests.py b/apps/projects/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/projects/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/projects/urls.py b/apps/projects/urls.py new file mode 100644 index 0000000..8f7ef39 --- /dev/null +++ b/apps/projects/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from . import views + +urlpatterns = [path("projects/", views.project_list, name="project-list")] diff --git a/apps/projects/views.py b/apps/projects/views.py new file mode 100644 index 0000000..574d7b3 --- /dev/null +++ b/apps/projects/views.py @@ -0,0 +1,12 @@ +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.shortcuts import render + +from .models import Project + +@login_required +def project_list(request): + if not request.user.is_app_admin: + raise PermissionDenied + projects = Project.objects.select_related("company").prefetch_related("memberships__user") + return render(request, "management/project_list.html", {"projects": projects}) diff --git a/apps/reports/__init__.py b/apps/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reports/admin.py b/apps/reports/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/reports/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/reports/apps.py b/apps/reports/apps.py new file mode 100644 index 0000000..a79748c --- /dev/null +++ b/apps/reports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.reports' diff --git a/apps/reports/migrations/__init__.py b/apps/reports/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reports/models.py b/apps/reports/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/apps/reports/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/reports/pdf.py b/apps/reports/pdf.py new file mode 100644 index 0000000..159e02a --- /dev/null +++ b/apps/reports/pdf.py @@ -0,0 +1,71 @@ +from io import BytesIO +from pathlib import Path +from xml.sax.saxutils import escape + +from reportlab.lib import colors +from reportlab.lib.enums import TA_RIGHT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + + +def _font_name(): + candidates = ( + Path("C:/Windows/Fonts/arial.ttf"), + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + ) + for path in candidates: + if path.exists(): + pdfmetrics.registerFont(TTFont("TimerrFont", path)) + return "TimerrFont" + return "Helvetica" + + +def _duration(minutes): + hours, remainder = divmod(int(minutes), 60) + return f"{hours} ч {remainder:02d} мин" + + +def render_company_pdf(report, company): + output = BytesIO() + font = _font_name() + styles = getSampleStyleSheet() + normal = ParagraphStyle("TimerrNormal", parent=styles["Normal"], fontName=font, fontSize=9, leading=12) + heading = ParagraphStyle("TimerrHeading", parent=styles["Heading1"], fontName=font, fontSize=18, leading=22) + subheading = ParagraphStyle("TimerrSubheading", parent=styles["Heading2"], fontName=font, fontSize=13, leading=16) + total = ParagraphStyle("TimerrTotal", parent=normal, fontSize=12, alignment=TA_RIGHT) + document = SimpleDocTemplate(output, pagesize=A4, rightMargin=15 * mm, leftMargin=15 * mm, topMargin=15 * mm, bottomMargin=15 * mm) + story = [ + Paragraph("Отчёт о выполненных работах", heading), + Spacer(1, 4 * mm), + Paragraph(f"Компания: {escape(company.name)}", normal), + Paragraph(f"Исполнитель: {escape(report['user'].get_full_name() or report['user'].username)}", normal), + Paragraph(f"Период: {report['month']:02d}.{report['year']}", normal), + Spacer(1, 4 * mm), + ] + for company_group in report["companies"]: + for project_group in company_group["projects"].values(): + story.append(Paragraph(escape(project_group["project"].name), subheading)) + rows = [["Дата", "Задача и описание", "Время", "Ставка", "Сумма"]] + for task in project_group["tasks"]: + rows.append([ + task.work_date.strftime("%d.%m.%Y"), + Paragraph(f"{escape(task.title)}
{escape(task.description)}", normal), + _duration(task.duration_minutes), f"{task.hourly_rate} ₽", f"{task.amount} ₽", + ]) + rows.append(["", "Итого по проекту", _duration(project_group["minutes"]), "", f"{project_group['amount']} ₽"]) + table = Table(rows, colWidths=(22 * mm, 78 * mm, 25 * mm, 25 * mm, 25 * mm), repeatRows=1) + table.setStyle(TableStyle([ + ("FONTNAME", (0, 0), (-1, -1), font), ("FONTSIZE", (0, 0), (-1, -1), 8), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e9edf4")), + ("GRID", (0, 0), (-1, -1), .35, colors.HexColor("#aeb7c5")), + ("VALIGN", (0, 0), (-1, -1), "TOP"), ("FONTNAME", (0, -1), (-1, -1), font), + ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ])) + story.extend((table, Spacer(1, 5 * mm))) + story.append(Paragraph(f"Итого: {_duration(company_group['minutes'])}, {company_group['amount']} ₽", total)) + document.build(story) + return output.getvalue() diff --git a/apps/reports/services.py b/apps/reports/services.py new file mode 100644 index 0000000..4141934 --- /dev/null +++ b/apps/reports/services.py @@ -0,0 +1,46 @@ +from collections import OrderedDict +from decimal import Decimal + +from apps.tasks.models import Task + + +def build_monthly_report(user, year, month, company=None): + tasks = ( + Task.objects.filter( + assignee=user, + work_date__year=year, + work_date__month=month, + status=Task.Status.COMPLETED, + ) + .select_related("project__company", "assignee") + .order_by("project__company__name", "project__name", "work_date", "id") + ) + if company is not None: + tasks = tasks.filter(project__company=company) + + companies = OrderedDict() + total_minutes = 0 + total_amount = Decimal("0") + for task in tasks: + company_data = companies.setdefault(task.project.company_id, { + "company": task.project.company, "projects": OrderedDict(), "minutes": 0, "amount": Decimal("0") + }) + project_data = company_data["projects"].setdefault(task.project_id, { + "project": task.project, "tasks": [], "minutes": 0, "amount": Decimal("0") + }) + project_data["tasks"].append(task) + project_data["minutes"] += task.duration_minutes + project_data["amount"] += task.amount + company_data["minutes"] += task.duration_minutes + company_data["amount"] += task.amount + total_minutes += task.duration_minutes + total_amount += task.amount + + return { + "user": user, + "year": year, + "month": month, + "companies": list(companies.values()), + "total_minutes": total_minutes, + "total_amount": total_amount, + } diff --git a/apps/reports/templatetags/__init__.py b/apps/reports/templatetags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/reports/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/reports/templatetags/report_tags.py b/apps/reports/templatetags/report_tags.py new file mode 100644 index 0000000..9ad8b30 --- /dev/null +++ b/apps/reports/templatetags/report_tags.py @@ -0,0 +1,10 @@ +from django import template + +register = template.Library() + + +@register.filter +def duration(minutes): + minutes = int(minutes or 0) + hours, remainder = divmod(minutes, 60) + return f"{hours} ч {remainder:02d} мин" diff --git a/apps/reports/test_reports.py b/apps/reports/test_reports.py new file mode 100644 index 0000000..cb39e65 --- /dev/null +++ b/apps/reports/test_reports.py @@ -0,0 +1,58 @@ +from datetime import date +from decimal import Decimal + +from django.test import TestCase +from django.urls import reverse + +from apps.accounts.models import User +from apps.companies.models import Company +from apps.projects.models import Project, ProjectMember +from apps.tasks.models import Task + +from .services import build_monthly_report + + +class MonthlyReportTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="worker", password="password123", must_change_password=False) + self.company_a = Company.objects.create(name="Клиент A") + self.company_b = Company.objects.create(name="Клиент B") + self.project_a = Project.objects.create(company=self.company_a, name="Проект A") + self.project_b = Project.objects.create(company=self.company_b, name="Проект B") + for project in (self.project_a, self.project_b): + ProjectMember.objects.create(project=project, user=self.user, hourly_rate=Decimal("1000.00")) + self._task(self.project_a, "Выполнена A", Task.Status.COMPLETED, 60) + self._task(self.project_b, "Выполнена B", Task.Status.COMPLETED, 30) + self._task(self.project_a, "Отменена", Task.Status.CANCELLED, 120) + + def _task(self, project, title, status, minutes): + return Task.objects.create( + project=project, assignee=self.user, title=title, description="Описание", work_date=date(2026, 7, 10), + duration_minutes=minutes, hourly_rate=Decimal("1000.00"), + amount=Task.calculate_amount(minutes, Decimal("1000.00")), status=status, created_by=self.user, + ) + + def test_internal_report_groups_companies_and_excludes_cancelled(self): + report = build_monthly_report(self.user, 2026, 7) + self.assertEqual(len(report["companies"]), 2) + self.assertEqual(report["total_minutes"], 90) + self.assertEqual(report["total_amount"], Decimal("1500.00")) + + def test_client_report_contains_only_selected_company(self): + report = build_monthly_report(self.user, 2026, 7, company=self.company_a) + self.assertEqual(len(report["companies"]), 1) + self.assertEqual(report["companies"][0]["company"], self.company_a) + self.assertEqual(report["total_minutes"], 60) + + def test_worker_cannot_request_other_users_report(self): + other = User.objects.create_user(username="other", password="password123", must_change_password=False) + self.client.force_login(self.user) + response = self.client.get(reverse("monthly-report"), {"user": other.pk, "year": 2026, "month": 7}) + self.assertEqual(response.context["report"]["user"], self.user) + + def test_client_pdf_is_generated_for_one_company(self): + self.client.force_login(self.user) + response = self.client.get(reverse("company-report-pdf", args=(self.company_a.pk,)), {"year": 2026, "month": 7}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertTrue(response.content.startswith(b"%PDF")) diff --git a/apps/reports/tests.py b/apps/reports/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/reports/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/reports/urls.py b/apps/reports/urls.py new file mode 100644 index 0000000..baf2014 --- /dev/null +++ b/apps/reports/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.monthly_report, name="monthly-report"), + path("company//pdf/", views.company_pdf, name="company-report-pdf"), +] diff --git a/apps/reports/views.py b/apps/reports/views.py new file mode 100644 index 0000000..7baa802 --- /dev/null +++ b/apps/reports/views.py @@ -0,0 +1,52 @@ +from datetime import date + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, render + +from apps.accounts.models import User +from apps.companies.models import Company + +from .services import build_monthly_report +from .pdf import render_company_pdf + + +def selected_user(request): + if not request.user.is_app_admin: + return request.user + user_id = request.GET.get("user") + return get_object_or_404(User, pk=user_id) if user_id else request.user + + +def selected_period(request): + today = date.today() + return int(request.GET.get("year", today.year)), int(request.GET.get("month", today.month)) + + +@login_required +def monthly_report(request): + user = selected_user(request) + year, month = selected_period(request) + report = build_monthly_report(user, year, month) + context = { + "report": report, + "users": User.objects.filter(is_active=True).order_by("last_name", "first_name", "username") if request.user.is_app_admin else (), + "companies": Company.objects.filter(projects__tasks__assignee=user, projects__tasks__work_date__year=year, projects__tasks__work_date__month=month).distinct(), + } + return render(request, "reports/monthly.html", context) + + +@login_required +def company_pdf(request, company_id): + user = selected_user(request) + if not request.user.is_app_admin and user.pk != request.user.pk: + raise PermissionDenied + year, month = selected_period(request) + company = get_object_or_404(Company, pk=company_id) + report = build_monthly_report(user, year, month, company=company) + pdf = render_company_pdf(report, company) + filename = f"report-{company.pk}-{user.username}-{year}-{month:02d}.pdf" + response = HttpResponse(pdf, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response diff --git a/apps/tasks/__init__.py b/apps/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tasks/admin.py b/apps/tasks/admin.py new file mode 100644 index 0000000..fb6a6a2 --- /dev/null +++ b/apps/tasks/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import Task + +@admin.register(Task) +class TaskAdmin(admin.ModelAdmin): + list_display = ("work_date", "title", "project", "assignee", "duration_minutes", "amount", "status") + list_filter = ("status", "project__company", "project", "work_date") + search_fields = ("title", "description", "assignee__username") + readonly_fields = ("hourly_rate", "amount", "created_by", "created_at", "updated_at") + + def save_model(self, request, obj, form, change): + if not obj.created_by_id: + obj.created_by = request.user + obj.apply_current_rate() + obj.full_clean() + super().save_model(request, obj, form, change) diff --git a/apps/tasks/apps.py b/apps/tasks/apps.py new file mode 100644 index 0000000..a82c8c1 --- /dev/null +++ b/apps/tasks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TasksConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.tasks' diff --git a/apps/tasks/forms.py b/apps/tasks/forms.py new file mode 100644 index 0000000..c6ebaa8 --- /dev/null +++ b/apps/tasks/forms.py @@ -0,0 +1,68 @@ +from django import forms +from django.utils import timezone + +from apps.projects.models import Project, ProjectMember + +from .models import Task + + +class TaskForm(forms.ModelForm): + duration_hours = forms.IntegerField(label="Часы", min_value=0, initial=0) + duration_minutes_input = forms.IntegerField(label="Минуты", min_value=0, max_value=59, initial=0) + + class Meta: + model = Task + fields = ("project", "assignee", "title", "description", "work_date", "status") + widgets = {"work_date": forms.DateInput(attrs={"type": "date"}), "description": forms.Textarea(attrs={"rows": 4})} + + def __init__(self, *args, user, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.order_fields(( + "project", "assignee", "title", "description", "work_date", + "duration_hours", "duration_minutes_input", "status", + )) + if self.instance.pk: + hours, minutes = divmod(self.instance.duration_minutes, 60) + self.fields["duration_hours"].initial = hours + self.fields["duration_minutes_input"].initial = minutes + elif not self.is_bound: + self.fields["work_date"].initial = timezone.localdate() + active_memberships = ProjectMember.objects.filter(is_active=True, project__is_active=True, project__company__is_active=True) + if user.is_app_admin: + self.fields["project"].queryset = Project.objects.filter(is_active=True, company__is_active=True) + self.fields["assignee"].queryset = user.__class__.objects.filter(is_active=True, project_memberships__in=active_memberships).distinct() + else: + self.fields["project"].queryset = Project.objects.filter(memberships__user=user, memberships__is_active=True, is_active=True, company__is_active=True) + self.fields["assignee"].queryset = user.__class__.objects.filter(pk=user.pk) + self.fields["assignee"].initial = user + self.fields["assignee"].widget = forms.HiddenInput() + if self.instance.pk: + self.fields["project"].disabled = True + + def clean(self): + cleaned = super().clean() + project = cleaned.get("project") + assignee = cleaned.get("assignee") + if not self.user.is_app_admin: + assignee = self.user + cleaned["assignee"] = self.user + if project and assignee and not ProjectMember.objects.filter(project=project, user=assignee, is_active=True).exists(): + self.add_error("assignee", "Пользователь не является активным участником проекта.") + hours = cleaned.get("duration_hours") + minutes = cleaned.get("duration_minutes_input") + if hours is not None and minutes is not None: + total = hours * 60 + minutes + if total <= 0: + self.add_error("duration_minutes_input", "Укажите продолжительность больше нуля.") + else: + cleaned["duration_minutes"] = total + return cleaned + + def save(self, commit=True): + task = super().save(commit=False) + task.duration_minutes = self.cleaned_data["duration_minutes"] + if commit: + task.save() + self.save_m2m() + return task diff --git a/apps/tasks/migrations/0001_initial.py b/apps/tasks/migrations/0001_initial.py new file mode 100644 index 0000000..8e4417f --- /dev/null +++ b/apps/tasks/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.16 on 2026-07-14 18:48 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Задача')), + ('description', models.TextField(verbose_name='Описание выполненной работы')), + ('work_date', models.DateField(verbose_name='Дата выполнения')), + ('duration_minutes', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)], verbose_name='Продолжительность, минут')), + ('hourly_rate', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='Ставка')), + ('amount', models.DecimalField(decimal_places=2, max_digits=14, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='Сумма')), + ('status', models.CharField(choices=[('completed', 'Выполнена'), ('cancelled', 'Отменена')], default='completed', max_length=16, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('assignee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tasks', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_tasks', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tasks', to='projects.project')), + ], + options={ + 'ordering': ('-work_date', '-id'), + 'indexes': [models.Index(fields=['assignee', 'work_date'], name='tasks_task_assigne_05f1cc_idx'), models.Index(fields=['project', 'work_date'], name='tasks_task_project_d20f81_idx'), models.Index(fields=['status', 'work_date'], name='tasks_task_status_1add49_idx')], + }, + ), + ] diff --git a/apps/tasks/migrations/__init__.py b/apps/tasks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tasks/models.py b/apps/tasks/models.py new file mode 100644 index 0000000..1151135 --- /dev/null +++ b/apps/tasks/models.py @@ -0,0 +1,55 @@ +from decimal import Decimal, ROUND_HALF_UP + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models + +from apps.projects.models import Project, ProjectMember + +class Task(models.Model): + class Status(models.TextChoices): + COMPLETED = "completed", "Выполнена" + CANCELLED = "cancelled", "Отменена" + + project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="tasks") + assignee = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="tasks") + title = models.CharField("Задача", max_length=200) + description = models.TextField("Описание выполненной работы") + work_date = models.DateField("Дата выполнения") + duration_minutes = models.PositiveIntegerField("Продолжительность, минут", validators=[MinValueValidator(1)]) + hourly_rate = models.DecimalField("Ставка", max_digits=12, decimal_places=2, validators=[MinValueValidator(Decimal("0"))]) + amount = models.DecimalField("Сумма", max_digits=14, decimal_places=2, validators=[MinValueValidator(Decimal("0"))]) + status = models.CharField("Статус", max_length=16, choices=Status.choices, default=Status.COMPLETED) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="created_tasks") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("-work_date", "-id") + indexes = [ + models.Index(fields=("assignee", "work_date")), + models.Index(fields=("project", "work_date")), + models.Index(fields=("status", "work_date")), + ] + + @staticmethod + def calculate_amount(duration_minutes, hourly_rate): + return (Decimal(duration_minutes) * Decimal(hourly_rate) / Decimal(60)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + def clean(self): + super().clean() + if not self.project_id or not self.assignee_id: + return + if not ProjectMember.objects.filter(project_id=self.project_id, user_id=self.assignee_id, is_active=True).exists(): + raise ValidationError({"assignee": "Пользователь не является активным участником проекта."}) + if not self.project.is_active or not self.project.company.is_active: + raise ValidationError({"project": "Компания и проект должны быть активны."}) + + def apply_current_rate(self): + membership = ProjectMember.objects.get(project=self.project, user=self.assignee, is_active=True) + self.hourly_rate = membership.hourly_rate + self.amount = self.calculate_amount(self.duration_minutes, self.hourly_rate) + + def __str__(self): + return f"{self.work_date}: {self.title}" diff --git a/apps/tasks/test_tasks.py b/apps/tasks/test_tasks.py new file mode 100644 index 0000000..def8df8 --- /dev/null +++ b/apps/tasks/test_tasks.py @@ -0,0 +1,92 @@ +from datetime import date +from decimal import Decimal + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.models import User +from apps.companies.models import Company +from apps.projects.models import Project, ProjectMember + +from .models import Task + + +class TaskAccessTests(TestCase): + def setUp(self): + self.worker = User.objects.create_user(username="worker", password="password123", must_change_password=False) + self.other = User.objects.create_user(username="other", password="password123", must_change_password=False) + self.admin = User.objects.create_user(username="admin", password="password123", role=User.Role.ADMIN, must_change_password=False) + self.company = Company.objects.create(name="Клиент") + self.project = Project.objects.create(company=self.company, name="Проект") + ProjectMember.objects.create(project=self.project, user=self.worker, hourly_rate=Decimal("1000.00")) + ProjectMember.objects.create(project=self.project, user=self.other, hourly_rate=Decimal("1200.00")) + self.task = Task.objects.create( + project=self.project, assignee=self.other, title="Чужая задача", description="Описание", + work_date=date(2026, 7, 14), duration_minutes=60, hourly_rate=Decimal("1200.00"), + amount=Decimal("1200.00"), created_by=self.other, + ) + + def test_worker_sees_only_own_tasks(self): + self.client.force_login(self.worker) + response = self.client.get(reverse("task-list")) + self.assertNotContains(response, "Чужая задача") + + def test_worker_cannot_edit_another_users_task(self): + self.client.force_login(self.worker) + response = self.client.get(reverse("task-edit", args=(self.task.pk,))) + self.assertEqual(response.status_code, 403) + + def test_create_copies_rate_and_calculates_rounded_amount(self): + self.client.force_login(self.worker) + response = self.client.post(reverse("task-create"), { + "project": self.project.pk, "assignee": self.worker.pk, "title": "Работа", + "description": "Сделано", "work_date": "2026-07-14", "duration_hours": 1, + "duration_minutes_input": 1, + "status": Task.Status.COMPLETED, + }) + self.assertRedirects(response, reverse("task-list")) + task = Task.objects.get(title="Работа") + self.assertEqual(task.hourly_rate, Decimal("1000.00")) + self.assertEqual(task.amount, Decimal("1016.67")) + + def test_admin_can_see_all_tasks(self): + self.client.force_login(self.admin) + response = self.client.get(reverse("task-list")) + self.assertContains(response, "Чужая задача") + + def test_new_task_defaults_to_current_date(self): + self.client.force_login(self.worker) + response = self.client.get(reverse("task-create")) + self.assertEqual(response.context["form"].fields["work_date"].initial, timezone.localdate()) + + def test_duration_must_be_greater_than_zero(self): + self.client.force_login(self.worker) + response = self.client.post(reverse("task-create"), { + "project": self.project.pk, "assignee": self.worker.pk, "title": "Работа", + "description": "Сделано", "work_date": "2026-07-14", "duration_hours": 0, + "duration_minutes_input": 0, "status": Task.Status.COMPLETED, + }) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Укажите продолжительность больше нуля") + + def test_duration_edit_keeps_rate_snapshot(self): + own_task = Task.objects.create( + project=self.project, assignee=self.worker, title="Старая работа", description="Описание", + work_date=date(2026, 7, 14), duration_minutes=60, hourly_rate=Decimal("1000.00"), + amount=Decimal("1000.00"), created_by=self.worker, + ) + membership = ProjectMember.objects.get(project=self.project, user=self.worker) + membership.hourly_rate = Decimal("2000.00") + membership.save() + self.client.force_login(self.worker) + response = self.client.post(reverse("task-edit", args=(own_task.pk,)), { + "project": self.project.pk, "assignee": self.worker.pk, "title": own_task.title, + "description": own_task.description, "work_date": "2026-07-14", "duration_hours": 2, + "duration_minutes_input": 0, + "status": Task.Status.COMPLETED, + }) + self.assertRedirects(response, reverse("task-list")) + own_task.refresh_from_db() + self.assertEqual(own_task.hourly_rate, Decimal("1000.00")) + self.assertEqual(own_task.amount, Decimal("2000.00")) diff --git a/apps/tasks/tests.py b/apps/tasks/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/tasks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/tasks/urls.py b/apps/tasks/urls.py new file mode 100644 index 0000000..4d57ac5 --- /dev/null +++ b/apps/tasks/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.task_list, name="task-list"), + path("create/", views.task_create, name="task-create"), + path("/edit/", views.task_edit, name="task-edit"), +] diff --git a/apps/tasks/views.py b/apps/tasks/views.py new file mode 100644 index 0000000..1c383dd --- /dev/null +++ b/apps/tasks/views.py @@ -0,0 +1,61 @@ +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect, render + +from .forms import TaskForm +from .models import Task + + +def visible_tasks(user): + queryset = Task.objects.select_related("project__company", "assignee") + return queryset if user.is_app_admin else queryset.filter(assignee=user) + + +@login_required +def task_list(request): + tasks = visible_tasks(request.user) + if request.GET.get("status"): + tasks = tasks.filter(status=request.GET["status"]) + if request.GET.get("month"): + year, month = map(int, request.GET["month"].split("-")) + tasks = tasks.filter(work_date__year=year, work_date__month=month) + return render(request, "tasks/task_list.html", {"tasks": tasks, "statuses": Task.Status.choices}) + + +@login_required +@transaction.atomic +def task_create(request): + form = TaskForm(request.POST or None, user=request.user) + if request.method == "POST" and form.is_valid(): + task = form.save(commit=False) + task.created_by = request.user + if not request.user.is_app_admin: + task.assignee = request.user + task.apply_current_rate() + task.full_clean() + task.save() + return redirect("task-list") + return render(request, "tasks/task_form.html", {"form": form, "heading": "Новая задача"}) + + +@login_required +@transaction.atomic +def task_edit(request, pk): + task = get_object_or_404(Task.objects.select_related("project__company", "assignee"), pk=pk) + if not request.user.is_app_admin and task.assignee_id != request.user.id: + raise PermissionDenied + old_scope = (task.project_id, task.assignee_id) + form = TaskForm(request.POST or None, instance=task, user=request.user) + if request.method == "POST" and form.is_valid(): + task = form.save(commit=False) + if not request.user.is_app_admin: + task.assignee = request.user + if old_scope != (task.project_id, task.assignee_id): + task.apply_current_rate() + elif {"duration_hours", "duration_minutes_input"} & set(form.changed_data): + task.amount = task.calculate_amount(task.duration_minutes, task.hourly_rate) + task.full_clean() + task.save() + return redirect("task-list") + return render(request, "tasks/task_form.html", {"form": form, "heading": "Редактирование задачи", "task": task}) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..d69183b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,15 @@ +services: + web: + build: . + ports: + - "8000:8000" + environment: + DEBUG: "1" + SECRET_KEY: "change-me" + ALLOWED_HOSTS: "localhost,127.0.0.1" + DATABASE_NAME: "/app/data/timerr.sqlite3" + volumes: + - timerr-data:/app/data + +volumes: + timerr-data: diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ed7c431 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config 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/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..d756b59 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,138 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 5.2.16. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +import os +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/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-development-only") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv("DEBUG", "1") == "1" + +ALLOWED_HOSTS = [host for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if host] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'apps.accounts', + 'apps.companies', + 'apps.projects', + 'apps.tasks', + 'apps.reports', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'apps.accounts.middleware.PasswordChangeRequiredMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.getenv('DATABASE_NAME', BASE_DIR / 'db.sqlite3'), + 'OPTIONS': {'timeout': 20}, + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.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/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru' + +TIME_ZONE = os.getenv('TIME_ZONE', 'Asia/Yekaterinburg') + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = 'static/' +STATICFILES_DIRS = [BASE_DIR / 'static'] +STATIC_ROOT = BASE_DIR / 'staticfiles' + +AUTH_USER_MODEL = 'accounts.User' +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'task-list' +LOGOUT_REDIRECT_URL = 'login' +APP_CURRENCY = os.getenv('APP_CURRENCY', 'RUB') + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..bec936c --- /dev/null +++ b/config/urls.py @@ -0,0 +1,27 @@ +""" +URL configuration for config project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.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('django-admin/', admin.site.urls), + path('', include('apps.accounts.urls')), + path('tasks/', include('apps.tasks.urls')), + path('reports/', include('apps.reports.urls')), + path('management/', include('apps.companies.urls')), + path('management/', include('apps.projects.urls')), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..e2fbd58 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config 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/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -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', 'config.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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..303e7f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=75"] +build-backend = "setuptools.build_meta" + +[project] +name = "timerr" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "Django>=5.2,<5.3", + "reportlab>=4.2,<5", +] + +[project.optional-dependencies] +dev = ["pytest>=8,<10", "pytest-django>=4.9,<5"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings" +python_files = ["test_*.py", "tests.py"] + +[tool.setuptools.packages.find] +include = ["apps*", "config*"] diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..7c66705 --- /dev/null +++ b/static/app.css @@ -0,0 +1 @@ +:root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#192033;background:#f4f6fa}*{box-sizing:border-box}body{margin:0}header{height:64px;background:#172033;color:white;display:flex;align-items:center;padding:0 max(24px,calc((100% - 1200px)/2));gap:36px}header a{color:white;text-decoration:none}.brand{font-size:22px;font-weight:800}nav{display:flex;align-items:center;gap:20px;margin-left:auto}nav form{margin:0}.link{background:none;border:0;color:white;padding:0}main{max-width:1200px;margin:32px auto;padding:0 24px}h1{margin-top:0}.card{background:white;border-radius:10px;padding:28px;box-shadow:0 3px 16px #18243b12}.narrow{max-width:480px;margin:60px auto}.toolbar,.filters{display:flex;align-items:center;gap:16px;justify-content:space-between}.filters{justify-content:flex-start;background:white;padding:16px;border-radius:8px;margin-bottom:22px;flex-wrap:wrap}label{font-weight:600}input,select,textarea{display:block;width:100%;padding:9px;border:1px solid #bac2d0;border-radius:6px;margin-top:5px}button,.button{border:0;border-radius:6px;background:#2f67dc;color:white;padding:10px 16px;text-decoration:none;cursor:pointer;font-weight:600}.secondary{background:#5a6477}table{width:100%;border-collapse:collapse;background:white}th,td{padding:11px;border-bottom:1px solid #dde2ea;text-align:left;vertical-align:top}th{background:#e9edf4}.table-wrap{overflow:auto}small{color:#647086}.total,.company-total,.grand-total{font-weight:700}.grand-total{font-size:20px;text-align:right}.report-company{margin:24px 0 36px}.errorlist{color:#b42318}.duration-fields{display:flex;gap:16px;border:0;padding:0;margin:20px 0}.duration-fields legend{font-weight:700;margin-bottom:8px;width:100%}.duration-fields label{width:150px}.duration-fields input{min-width:0} diff --git a/templates/accounts/login.html b/templates/accounts/login.html new file mode 100644 index 0000000..8e758f8 --- /dev/null +++ b/templates/accounts/login.html @@ -0,0 +1,3 @@ +{% extends 'base.html' %}{% block title %}Вход — Timerr{% endblock %}{% block content %} +

Вход

{% csrf_token %}{{ form.as_p }}
+{% endblock %} diff --git a/templates/accounts/password_change.html b/templates/accounts/password_change.html new file mode 100644 index 0000000..f82450f --- /dev/null +++ b/templates/accounts/password_change.html @@ -0,0 +1,2 @@ +{% extends 'base.html' %}{% block content %}

Смена пароля

+

Перед продолжением установите собственный пароль.

{% csrf_token %}{{ form.as_p }}
{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..c022432 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,8 @@ +{% load static %} + +{% block title %}Timerr{% endblock %} +
Timerr{% if user.is_authenticated %}{% endif %}
+
{% block content %}{% endblock %}
diff --git a/templates/management/company_list.html b/templates/management/company_list.html new file mode 100644 index 0000000..9c14531 --- /dev/null +++ b/templates/management/company_list.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}

Компании

Создание и изменение пока выполняется через панель администрирования.

{% for company in companies %}{% endfor %}
НазваниеСостояние
{{ company.name }}{% if company.is_active %}Активна{% else %}Отключена{% endif %}
{% endblock %} diff --git a/templates/management/project_list.html b/templates/management/project_list.html new file mode 100644 index 0000000..9f63cde --- /dev/null +++ b/templates/management/project_list.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}

Проекты

Создание, ставки и участники пока настраиваются через панель администрирования.

{% for project in projects %}{% endfor %}
КомпанияПроектУчастникиСостояние
{{ project.company }}{{ project.name }}{% for member in project.memberships.all %}{{ member.user }} — {{ member.hourly_rate }} ₽/ч{% if not forloop.last %}
{% endif %}{% endfor %}
{% if project.is_active %}Активен{% else %}Отключён{% endif %}
{% endblock %} diff --git a/templates/reports/monthly.html b/templates/reports/monthly.html new file mode 100644 index 0000000..23fdf64 --- /dev/null +++ b/templates/reports/monthly.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %}{% load report_tags %}{% block content %}

Месячный отчёт

+
{% if user.is_app_admin %}{% endif %}
+

{{ report.user.get_full_name|default:report.user.username }}

+{% for group in report.companies %}

{{ group.company.name }}

PDF для клиента
+{% for project in group.projects.values %}

{{ project.project.name }}

{% for task in project.tasks %}{% endfor %}
ДатаЗадачаОписаниеВремяСтавкаСумма
{{ task.work_date|date:'d.m.Y' }}{{ task.title }}{{ task.description }}{{ task.duration_minutes|duration }}{{ task.hourly_rate }} ₽{{ task.amount }} ₽
Итого по проекту{{ project.minutes|duration }}{{ project.amount }} ₽
{% endfor %} +

Итого по компании: {{ group.minutes|duration }}, {{ group.amount }} ₽

{% empty %}

За выбранный месяц выполненных задач нет.

{% endfor %} +

Общий итог: {{ report.total_minutes|duration }}, {{ report.total_amount }} ₽

{% endblock %} diff --git a/templates/tasks/task_form.html b/templates/tasks/task_form.html new file mode 100644 index 0000000..79aecaa --- /dev/null +++ b/templates/tasks/task_form.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %}{% block content %}

{{ heading }}

{% csrf_token %} +{{ form.non_field_errors }} +

{{ form.project.label_tag }}{{ form.project }}{{ form.project.errors }}

+{% if not form.assignee.is_hidden %}

{{ form.assignee.label_tag }}{{ form.assignee }}{{ form.assignee.errors }}

{% else %}{{ form.assignee }}{% endif %} +

{{ form.title.label_tag }}{{ form.title }}{{ form.title.errors }}

+

{{ form.description.label_tag }}{{ form.description }}{{ form.description.errors }}

+

{{ form.work_date.label_tag }}{{ form.work_date }}{{ form.work_date.errors }}

+
Продолжительность работы + + +
+

{{ form.status.label_tag }}{{ form.status }}{{ form.status.errors }}

+ Отмена
{% endblock %} diff --git a/templates/tasks/task_list.html b/templates/tasks/task_list.html new file mode 100644 index 0000000..140f5af --- /dev/null +++ b/templates/tasks/task_list.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %}{% load report_tags %}{% block content %} +
+
+{% for task in tasks %} +{% empty %}{% endfor %}
ДатаКомпания / проектЗадачаИсполнительВремяСтавкаСуммаСтатус
{{ task.work_date|date:'d.m.Y' }}{{ task.project.company.name }}
{{ task.project.name }}
{{ task.title }}
{{ task.description|truncatechars:80 }}
{{ task.assignee.get_full_name|default:task.assignee.username }}{{ task.duration_minutes|duration }}{{ task.hourly_rate }} ₽{{ task.amount }} ₽{{ task.get_status_display }}
Задач пока нет.
{% endblock %} diff --git a/timerr.egg-info/PKG-INFO b/timerr.egg-info/PKG-INFO new file mode 100644 index 0000000..c2bbec3 --- /dev/null +++ b/timerr.egg-info/PKG-INFO @@ -0,0 +1,9 @@ +Metadata-Version: 2.4 +Name: timerr +Version: 0.1.0 +Requires-Python: >=3.12 +Requires-Dist: Django<5.3,>=5.2 +Requires-Dist: reportlab<5,>=4.2 +Provides-Extra: dev +Requires-Dist: pytest<10,>=8; extra == "dev" +Requires-Dist: pytest-django<5,>=4.9; extra == "dev" diff --git a/timerr.egg-info/SOURCES.txt b/timerr.egg-info/SOURCES.txt new file mode 100644 index 0000000..e1df5c0 --- /dev/null +++ b/timerr.egg-info/SOURCES.txt @@ -0,0 +1,66 @@ +README.md +pyproject.toml +apps/__init__.py +apps/accounts/__init__.py +apps/accounts/admin.py +apps/accounts/apps.py +apps/accounts/forms.py +apps/accounts/middleware.py +apps/accounts/models.py +apps/accounts/tests.py +apps/accounts/urls.py +apps/accounts/views.py +apps/accounts/migrations/0001_initial.py +apps/accounts/migrations/__init__.py +apps/companies/__init__.py +apps/companies/admin.py +apps/companies/apps.py +apps/companies/models.py +apps/companies/tests.py +apps/companies/urls.py +apps/companies/views.py +apps/companies/migrations/0001_initial.py +apps/companies/migrations/__init__.py +apps/projects/__init__.py +apps/projects/admin.py +apps/projects/apps.py +apps/projects/models.py +apps/projects/tests.py +apps/projects/urls.py +apps/projects/views.py +apps/projects/migrations/0001_initial.py +apps/projects/migrations/__init__.py +apps/reports/__init__.py +apps/reports/admin.py +apps/reports/apps.py +apps/reports/models.py +apps/reports/pdf.py +apps/reports/services.py +apps/reports/test_reports.py +apps/reports/tests.py +apps/reports/urls.py +apps/reports/views.py +apps/reports/migrations/__init__.py +apps/reports/templatetags/__init__.py +apps/reports/templatetags/report_tags.py +apps/tasks/__init__.py +apps/tasks/admin.py +apps/tasks/apps.py +apps/tasks/forms.py +apps/tasks/models.py +apps/tasks/test_tasks.py +apps/tasks/tests.py +apps/tasks/urls.py +apps/tasks/views.py +apps/tasks/migrations/0001_initial.py +apps/tasks/migrations/__init__.py +config/__init__.py +config/asgi.py +config/settings.py +config/urls.py +config/wsgi.py +timerr.egg-info/PKG-INFO +timerr.egg-info/SOURCES.txt +timerr.egg-info/dependency_links.txt +timerr.egg-info/requires.txt +timerr.egg-info/top_level.txt \ No newline at end of file diff --git a/timerr.egg-info/dependency_links.txt b/timerr.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/timerr.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/timerr.egg-info/requires.txt b/timerr.egg-info/requires.txt new file mode 100644 index 0000000..ca82f45 --- /dev/null +++ b/timerr.egg-info/requires.txt @@ -0,0 +1,6 @@ +Django<5.3,>=5.2 +reportlab<5,>=4.2 + +[dev] +pytest<10,>=8 +pytest-django<5,>=4.9 diff --git a/timerr.egg-info/top_level.txt b/timerr.egg-info/top_level.txt new file mode 100644 index 0000000..53cfcb5 --- /dev/null +++ b/timerr.egg-info/top_level.txt @@ -0,0 +1,2 @@ +apps +config