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
+7
View File
@@ -0,0 +1,7 @@
.git
.venv
__pycache__
*.pyc
.pytest_cache
db.sqlite3
.env
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
db.sqlite3
staticfiles/
.env
+7
View File
@@ -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"]
+24
View File
@@ -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
```
+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})
+15
View File
@@ -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:
View File
+16
View File
@@ -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()
+138
View File
@@ -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'
+27
View File
@@ -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')),
]
+16
View File
@@ -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()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', '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()
+22
View File
@@ -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*"]
+1
View File
@@ -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}
+3
View File
@@ -0,0 +1,3 @@
{% extends 'base.html' %}{% block title %}Вход — Timerr{% endblock %}{% block content %}
<section class="card narrow"><h1>Вход</h1><form method="post">{% csrf_token %}{{ form.as_p }}<button>Войти</button></form></section>
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
{% extends 'base.html' %}{% block content %}<section class="card narrow"><h1>Смена пароля</h1>
<p>Перед продолжением установите собственный пароль.</p><form method="post">{% csrf_token %}{{ form.as_p }}<button>Сохранить пароль</button></form></section>{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% load static %}<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}Timerr{% endblock %}</title><link rel="stylesheet" href="{% static 'app.css' %}"></head>
<body><header><a class="brand" href="{% url 'task-list' %}">Timerr</a>{% if user.is_authenticated %}<nav>
<a href="{% url 'task-list' %}">Задачи</a><a href="{% url 'monthly-report' %}">Отчёты</a>
{% if user.is_app_admin %}<a href="{% url 'company-list' %}">Компании</a><a href="{% url 'project-list' %}">Проекты</a><a href="/django-admin/">Администрирование</a>{% endif %}
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button class="link">Выйти</button></form></nav>{% endif %}</header>
<main>{% block content %}{% endblock %}</main></body></html>
+1
View File
@@ -0,0 +1 @@
{% extends 'base.html' %}{% block content %}<h1>Компании</h1><p>Создание и изменение пока выполняется через <a href="/django-admin/companies/company/">панель администрирования</a>.</p><table><tr><th>Название</th><th>Состояние</th></tr>{% for company in companies %}<tr><td>{{ company.name }}</td><td>{% if company.is_active %}Активна{% else %}Отключена{% endif %}</td></tr>{% endfor %}</table>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends 'base.html' %}{% block content %}<h1>Проекты</h1><p>Создание, ставки и участники пока настраиваются через <a href="/django-admin/projects/project/">панель администрирования</a>.</p><table><tr><th>Компания</th><th>Проект</th><th>Участники</th><th>Состояние</th></tr>{% for project in projects %}<tr><td>{{ project.company }}</td><td>{{ project.name }}</td><td>{% for member in project.memberships.all %}{{ member.user }} — {{ member.hourly_rate }} ₽/ч{% if not forloop.last %}<br>{% endif %}{% endfor %}</td><td>{% if project.is_active %}Активен{% else %}Отключён{% endif %}</td></tr>{% endfor %}</table>{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends 'base.html' %}{% load report_tags %}{% block content %}<h1>Месячный отчёт</h1>
<form method="get" class="filters">{% if user.is_app_admin %}<label>Исполнитель <select name="user">{% for item in users %}<option value="{{ item.pk }}" {% if report.user.pk == item.pk %}selected{% endif %}>{{ item.get_full_name|default:item.username }}</option>{% endfor %}</select></label>{% endif %}<label>Месяц <input name="month" type="number" min="1" max="12" value="{{ report.month }}"></label><label>Год <input name="year" type="number" value="{{ report.year }}"></label><button>Показать</button></form>
<h2>{{ report.user.get_full_name|default:report.user.username }}</h2>
{% for group in report.companies %}<section class="report-company"><div class="toolbar"><h3>{{ group.company.name }}</h3><a class="button secondary" href="{% url 'company-report-pdf' group.company.pk %}?user={{ report.user.pk }}&year={{ report.year }}&month={{ report.month }}">PDF для клиента</a></div>
{% for project in group.projects.values %}<h4>{{ project.project.name }}</h4><table><thead><tr><th>Дата</th><th>Задача</th><th>Описание</th><th>Время</th><th>Ставка</th><th>Сумма</th></tr></thead><tbody>{% for task in project.tasks %}<tr><td>{{ task.work_date|date:'d.m.Y' }}</td><td>{{ task.title }}</td><td>{{ task.description }}</td><td>{{ task.duration_minutes|duration }}</td><td>{{ task.hourly_rate }} ₽</td><td>{{ task.amount }} ₽</td></tr>{% endfor %}<tr class="total"><td colspan="3">Итого по проекту</td><td>{{ project.minutes|duration }}</td><td></td><td>{{ project.amount }} ₽</td></tr></tbody></table>{% endfor %}
<p class="company-total">Итого по компании: {{ group.minutes|duration }}, {{ group.amount }} ₽</p></section>{% empty %}<p>За выбранный месяц выполненных задач нет.</p>{% endfor %}
<p class="grand-total">Общий итог: {{ report.total_minutes|duration }}, {{ report.total_amount }} ₽</p>{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends 'base.html' %}{% block content %}<section class="card"><h1>{{ heading }}</h1><form method="post">{% csrf_token %}
{{ form.non_field_errors }}
<p>{{ form.project.label_tag }}{{ form.project }}{{ form.project.errors }}</p>
{% if not form.assignee.is_hidden %}<p>{{ form.assignee.label_tag }}{{ form.assignee }}{{ form.assignee.errors }}</p>{% else %}{{ form.assignee }}{% endif %}
<p>{{ form.title.label_tag }}{{ form.title }}{{ form.title.errors }}</p>
<p>{{ form.description.label_tag }}{{ form.description }}{{ form.description.errors }}</p>
<p>{{ form.work_date.label_tag }}{{ form.work_date }}{{ form.work_date.errors }}</p>
<fieldset class="duration-fields"><legend>Продолжительность работы</legend>
<label>Часы{{ form.duration_hours }}{{ form.duration_hours.errors }}</label>
<label>Минуты{{ form.duration_minutes_input }}{{ form.duration_minutes_input.errors }}</label>
</fieldset>
<p>{{ form.status.label_tag }}{{ form.status }}{{ form.status.errors }}</p>
<button>Сохранить</button> <a href="{% url 'task-list' %}">Отмена</a></form></section>{% endblock %}
+5
View File
@@ -0,0 +1,5 @@
{% extends 'base.html' %}{% load report_tags %}{% block content %}<div class="toolbar"><h1>Задачи</h1><a class="button" href="{% url 'task-create' %}">Новая задача</a></div>
<form method="get" class="filters"><label>Месяц <input type="month" name="month" value="{{ request.GET.month }}"></label><label>Статус <select name="status"><option value="">Все</option>{% for value,label in statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>Применить</button></form>
<div class="table-wrap"><table><thead><tr><th>Дата</th><th>Компания / проект</th><th>Задача</th><th>Исполнитель</th><th>Время</th><th>Ставка</th><th>Сумма</th><th>Статус</th></tr></thead><tbody>
{% for task in tasks %}<tr><td>{{ task.work_date|date:'d.m.Y' }}</td><td>{{ task.project.company.name }}<br><small>{{ task.project.name }}</small></td><td><a href="{% url 'task-edit' task.pk %}">{{ task.title }}</a><br><small>{{ task.description|truncatechars:80 }}</small></td><td>{{ task.assignee.get_full_name|default:task.assignee.username }}</td><td>{{ task.duration_minutes|duration }}</td><td>{{ task.hourly_rate }} ₽</td><td>{{ task.amount }} ₽</td><td>{{ task.get_status_display }}</td></tr>
{% empty %}<tr><td colspan="8">Задач пока нет.</td></tr>{% endfor %}</tbody></table></div>{% endblock %}
+9
View File
@@ -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"
+66
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
Django<5.3,>=5.2
reportlab<5,>=4.2
[dev]
pytest<10,>=8
pytest-django<5,>=4.9
+2
View File
@@ -0,0 +1,2 @@
apps
config