init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.accounts'
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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})
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Company
|
||||
|
||||
admin.site.register(Company)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CompaniesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.companies'
|
||||
@@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [path("companies/", views.company_list, name="company-list")]
|
||||
@@ -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()})
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProjectsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.projects'
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [path("projects/", views.project_list, name="project-list")]
|
||||
@@ -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})
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReportsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.reports'
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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} мин"
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TasksConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.tasks'
|
||||
@@ -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
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user