Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 65 additions & 58 deletions TaskManagerSystem/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,29 @@
import os
from pathlib import Path
from datetime import timedelta
from decouple import config # type: ignore
from decouple import config

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

# Secret Key for production
# SECURITY
SECRET_KEY = config(
"SECRET_KEY", default=os.environ.get("SECRET_KEY", "fallback-secret-key")
)

# Debugging mode (change to False in production)
DEBUG = config("DEBUG", default=True, cast=bool)

# Security settings
SECURE_BROWSER_XSS_FILTER = True # enable X-XSS-Protection
SECURE_CONTENT_TYPE_NOSNIFF = True # enable X-Content-Type-Options
X_FRAME_OPTIONS = 'DENY' # prevent clickjacking

ALLOWED_HOSTS = config(
"ALLOWED_HOSTS",
default="localhost,127.0.0.1",
cast=lambda v: [s.strip() for s in v.split(",")],
)

AUTH_USER_MODEL = 'users.User'

# Security settings
SECURE_BROWSER_XSS_FILTER = True # enable X-XSS-Protection
SECURE_CONTENT_TYPE_NOSNIFF = True # enable X-Content-Type-Options
X_FRAME_OPTIONS = 'DENY' # prevent clickjacking


ADMIN_ROLE_NAMES = ["Admin",]
ROLE_PERMISSIONS = {
"Admin": [
Expand All @@ -55,29 +53,38 @@
"view_task",
],
}
ROLE_ORDER = tuple(reversed(ROLE_PERMISSIONS.keys()))

DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 25 # 25 Mb restriction
API_VERSION = '1.0.0'

# Application definition
INSTALLED_APPS = [
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.test",
"django.test"
]

THIRD_PARTY_APPS = [
"django_filters",
"drf_yasg",
"rest_framework",
"rest_framework_simplejwt.token_blacklist",
"drf_yasg",
"corsheaders",
]

LOCAL_APPS = [
"users",
"tasks",
"projects",
"corsheaders",
"projects"
]

# Application definition
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

# List of middleware classes to use
MIDDLEWARE = [
'TaskManagerSystem.middleware.RejectLargeRequestsMiddleware',
Expand All @@ -91,6 +98,38 @@
'django.middleware.clickjacking.XFrameOptionsMiddleware', # clickjacking protection middleware
]

# Root URL configuration
WSGI_APPLICATION = 'TaskManagerSystem.wsgi.application'
ROOT_URLCONF = 'TaskManagerSystem.urls'

# Templates configuration
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], # dir where templates are
'APP_DIRS': True, # automatically discover templates in each app's
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

# WSGI
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = False

# Static
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / "staticfiles"
# STATICFILES_DIRS = [BASE_DIR / "static"]

CORS_ALLOWED_ORIGINS = [
"http://127.0.0.1:3000", # Allow Next.js to the client
"http://localhost:3000", # Additionally for other options
Expand Down Expand Up @@ -136,6 +175,9 @@
"AUTH_HEADER_TYPES": ("Bearer",),
}

# Default primary key
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

# Database settings
DATABASES = {
'default': {
Expand All @@ -148,11 +190,9 @@
}
}

# Logging configuration
LOG_DIR = os.path.join(BASE_DIR, "logs")
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)

# Logging
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
LOGGING = {
'version': 1, # version of the logging configuration
'disable_existing_loggers': False, # dont disable existing loggers
Expand All @@ -177,14 +217,14 @@
},
'file': {
'class': 'logging.handlers.RotatingFileHandler', # log to a file
'filename': os.path.join(LOG_DIR, 'tms.log'),
'maxBytes': 1024 * 1024 * 5, # rotate logs every 5MB
'filename': LOG_DIR / 'tms.log',
'maxBytes': 5 * 1024 * 1024, # rotate logs every 5MB
'backupCount': 3,
'formatter': 'verbose',
},
'error_file': {
'class': 'logging.FileHandler',
'filename': os.path.join(LOG_DIR, 'errors.log'),
'filename': LOG_DIR / 'errors.log',
'formatter': 'verbose',
'level': 'ERROR', # only log ERROR and above level messages
},
Expand Down Expand Up @@ -223,36 +263,3 @@
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]

# Root URL configuration
ROOT_URLCONF = 'TaskManagerSystem.urls'

# Template configuration
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], # dir where templates are
'APP_DIRS': True, # automatically discover templates in each app's
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

WSGI_APPLICATION = 'TaskManagerSystem.wsgi.application'

LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = False

STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / "staticfiles"
# STATICFILES_DIRS = [BASE_DIR / "static"]

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
5 changes: 2 additions & 3 deletions api/tests/test_projects.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import timedelta
from django.conf import settings
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
Expand All @@ -12,8 +13,6 @@

User = get_user_model()

FIXED_ROLES = ["Admin", "Moderator", "Member", "Viewer"]

class ProjectsAPITests(BaseAPITestCase):
@classmethod
def setUpTestData(cls):
Expand All @@ -30,7 +29,7 @@ def create_user(cls, email, password):

@classmethod
def create_role(cls, name):
assert name in FIXED_ROLES, f"Role '{name}' is not fixed"
assert name in settings.ROLE_ORDER, f"Role '{name}' is not fixed"
return Role.objects.get_or_create(name=name)[0]

@classmethod
Expand Down
3 changes: 0 additions & 3 deletions api/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,6 @@ def test_filter_by_status(self):
self.future_task.save()

response = self.client.get(self.task_list_ep, {"completed": "True"})
print(response.data)

self.assertEqual(
response.status_code,
Expand All @@ -384,7 +383,6 @@ def test_filter_by_priority(self):
self.future_task.save()

response = self.client.get(self.task_list_ep, {"priority": "M"})
print(response.data)

self.assertEqual(
response.status_code,
Expand Down Expand Up @@ -413,7 +411,6 @@ def test_filter_by_today(self):
self.today_task.save()

response = self.client.get(self.task_list_ep, {"today": "true"})
print(response.data)

self.assertEqual(
response.status_code,
Expand Down
19 changes: 12 additions & 7 deletions api/tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ def test_user_login_invalid_credentials(self):
"Login with invalid credentials did not fail",
)
self.assertIn(
"Invalid credentials",
response.data.get("non_field_errors", ""),
"Invalid email or password",
response.data.get("detail", ""),
"Invalid credentials error not included in response",
)

Expand Down Expand Up @@ -149,13 +149,18 @@ def test_user_logout_invalid_token(self):

self.assertEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED,
"Logout with invalid token did not fail",
)
self.assertIn(
"Token is invalid or expired",
response.data.get("error", ""),
"Invalid token error not included in response",
error_text = response.data.get("error", "").lower()
self.assertTrue(
any(msg in error_text for msg in [
"incorrect token format",
"expired token",
"already been revoked",
"token is invalid or expired",
]),
f"Expected error message not found in response: {error_text}",
)

def test_user_registration_duplicate_email(self):
Expand Down
4 changes: 2 additions & 2 deletions api/validators.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from django.core.validators import RegexValidator

TEXT_FIELD_VALIDATOR = RegexValidator(
r"^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. -]+$",
"Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes and spaces",
r"^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$",
"Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces",
)

USERNAME_VALIDATOR = RegexValidator(
Expand Down
19 changes: 19 additions & 0 deletions projects/migrations/0005_alter_project_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 5.1.9 on 2025-05-20 14:01

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('projects', '0004_projectsharelink_is_active_alter_project_name'),
]

operations = [
migrations.AlterField(
model_name='project',
name='name',
field=models.CharField(max_length=128, validators=[django.core.validators.RegexValidator('^[a-zA-Zа-яА-ЯёЁіІїЇєЄґҐ0-9_. , -]+$', 'Text can contain letters (latin/cyrillic), numbers, underscores, dots, dashes, commas and spaces')]),
),
]
22 changes: 15 additions & 7 deletions projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@


class Project(models.Model):
"""Project with name, description, and owner"""
name = models.CharField(max_length=128, validators=[TEXT_FIELD_VALIDATOR])
description = models.TextField(blank=True)
owner = models.ForeignKey(
Expand All @@ -28,16 +29,15 @@ def __str__(self):


class Role(models.Model):
ADMIN, MODERATOR, MEMBER, VIEWER = "Admin", "Moderator", "Member", "Viewer"
FIXED_ROLES = [ADMIN, MODERATOR, MEMBER, VIEWER]

"""Static project roles; prohibition to create custom ones"""
name = models.CharField(max_length=64, unique=True)
permissions = models.ManyToManyField(Permission, blank=True)

def clean(self):
if self.name not in self.FIXED_ROLES:
fixed = settings.ROLE_ORDER
if self.name not in fixed:
raise ValidationError(
f"Custom roles are not allowed. Use one of: {', '.join(self.FIXED_ROLES)}"
f"Custom roles are not allowed. Use one of: {', '.join(fixed)}"
)

def save(self, *args, **kwargs):
Expand All @@ -49,6 +49,7 @@ def __str__(self):


class ProjectMembership(models.Model):
"""The relationship 'user --> project --> role' """
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
Expand All @@ -69,6 +70,7 @@ def __str__(self):


class ProjectShareLink(models.Model):
"""Token for invitation to the project with a limits"""
token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
project = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="share_links"
Expand All @@ -95,7 +97,9 @@ class ProjectShareLink(models.Model):
def clean(self):
errors = {}
if self.max_uses is not None and self.max_uses <= 0:
errors["max_uses"] = "The max_uses value must be a positive number or left blank"
errors["max_uses"] = (
"The max_uses value must be a positive number or left blank"
)
if self.max_uses is not None and self.used_count > self.max_uses:
errors["used_count"] = "The number of uses exceeds the set limit"
if self.expires_at <= timezone.now():
Expand All @@ -111,7 +115,11 @@ def is_usage_exceeded(self):
return self.max_uses is not None and self.used_count >= self.max_uses

def is_valid(self):
return self.is_active and not self.is_expired() and not self.is_usage_exceeded()
return (
self.is_active
and not self.is_expired()
and not self.is_usage_exceeded()
)

def __str__(self):
return f"Link to {self.project.name} ({self.role.name})"
Loading