init
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
db.sqlite3
|
||||
.env
|
||||
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
db.sqlite3
|
||||
staticfiles/
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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})
|
||||
@@ -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:
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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')),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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*"]
|
||||
@@ -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}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Django<5.3,>=5.2
|
||||
reportlab<5,>=4.2
|
||||
|
||||
[dev]
|
||||
pytest<10,>=8
|
||||
pytest-django<5,>=4.9
|
||||
@@ -0,0 +1,2 @@
|
||||
apps
|
||||
config
|
||||
Reference in New Issue
Block a user