This commit is contained in:
MS.Kondratev
2026-07-15 00:12:31 +05:00
commit 7beb1d9ca6
80 changed files with 1401 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
View File
+10
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.accounts'
+10
View File
@@ -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
+14
View File
@@ -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)
+48
View File
@@ -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()),
],
),
]
+27
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -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"),
]
+15
View File
@@ -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})
View File
+5
View File
@@ -0,0 +1,5 @@
from django.contrib import admin
from .models import Company
admin.site.register(Company)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CompaniesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.companies'
+29
View File
@@ -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',),
},
),
]
+16
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+5
View File
@@ -0,0 +1,5 @@
from django.urls import path
from . import views
urlpatterns = [path("companies/", views.company_list, name="company-list")]
+11
View File
@@ -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()})
View File
+20
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.projects'
+58
View File
@@ -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'),
),
]
+38
View File
@@ -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}"
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+5
View File
@@ -0,0 +1,5 @@
from django.urls import path
from . import views
urlpatterns = [path("projects/", views.project_list, name="project-list")]
+12
View File
@@ -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})
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReportsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.reports'
View File
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+71
View File
@@ -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"<b>Компания:</b> {escape(company.name)}", normal),
Paragraph(f"<b>Исполнитель:</b> {escape(report['user'].get_full_name() or report['user'].username)}", normal),
Paragraph(f"<b>Период:</b> {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"<b>{escape(task.title)}</b><br/>{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()
+46
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
+10
View File
@@ -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} мин"
+58
View File
@@ -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"))
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.monthly_report, name="monthly-report"),
path("company/<int:company_id>/pdf/", views.company_pdf, name="company-report-pdf"),
]
+52
View File
@@ -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
View File
+17
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TasksConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.tasks'
+68
View File
@@ -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
+42
View File
@@ -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')],
},
),
]
View File
+55
View File
@@ -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}"
+92
View File
@@ -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"))
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+9
View File
@@ -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("<int:pk>/edit/", views.task_edit, name="task-edit"),
]
+61
View File
@@ -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})