diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5492bc2..0e802a5 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -39,10 +39,10 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -103,6 +103,8 @@ jobs: uses: docker/build-push-action@v5 with: context: . + file: Dockerfile.django + load: ${{ github.event_name == 'pull_request' }} push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -112,7 +114,7 @@ jobs: - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: - image-ref: ${{ steps.meta.outputs.tags }} + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} format: 'table' exit-code: '1' ignore-unfixed: true diff --git a/AGENTS.md b/AGENTS.md index 0b16fa8..80ee29b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,26 +21,23 @@ techblog_cms is a Django-based technical blog content management system. It oper ## Project Structure ``` techblog_cms/ -├── app/ # Django application -│ ├── techblog_cms/ # Main Django app -│ │ ├── __init__.py -│ │ ├── settings.py # Django settings (using environment variables) -│ │ ├── urls.py # URL mapping -│ │ ├── views.py # View functions -│ │ ├── wsgi.py # WSGI entry point -│ │ └── templates/ # HTML templates -│ └── requirements.txt # Python dependencies +├── techblog_cms/ # Django application +│ ├── settings.py # Django settings (using environment variables) +│ ├── urls.py # URL mapping +│ ├── views.py # View functions +│ ├── wsgi.py # WSGI entry point +│ ├── templates/ # HTML templates +│ └── tests/ # Test files ├── nginx/ # Nginx configuration -│ ├── conf.d/ -│ │ └── default.conf # Nginx configuration file -│ └── Dockerfile # Nginx container definition +│ └── conf.d/ +│ └── default.conf # Nginx configuration file ├── scripts/ # Utility scripts │ ├── init-letsencrypt.sh # Initialize SSL certificate │ └── renew-cert.sh # Renew SSL certificate ├── static/ # Static files -├── tests/ # Test files ├── docker-compose.yml # Container orchestration -├── Dockerfile.* # Various Dockerfiles +├── Dockerfile.django # Django container definition +├── Dockerfile.nginx # Nginx container definition ├── requirements.txt # Project-wide dependencies └── pytest.ini # pytest configuration ``` diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c8e5e63..0000000 --- a/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# Set working directory first -WORKDIR /app - -# Create a non-root user with specific UID/GID -RUN addgroup --gid 1000 appgroup && \ - adduser --uid 1000 --gid 1000 --system --group appuser - -# Create logs directory and set permissions -RUN mkdir -p /app/logs && \ - chown -R appuser:appgroup /app && \ - chmod -R 775 /app && \ - chmod g+s /app/logs - -# Switch to non-root user -USER appuser:appgroup diff --git a/Dockerfile.django b/Dockerfile.django index 5540426..a3622b5 100644 --- a/Dockerfile.django +++ b/Dockerfile.django @@ -34,6 +34,7 @@ ENV PYTHONUNBUFFERED=1 # Copy requirements first for better caching COPY requirements.txt . +RUN pip install --upgrade pip setuptools wheel RUN pip install -r requirements.txt # Copy project files diff --git a/Dockerfile.nginx b/Dockerfile.nginx index 7954891..600cdf9 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -7,10 +7,9 @@ RUN rm /etc/nginx/conf.d/default.conf # Copy configuration and static files COPY nginx/nginx.conf /etc/nginx/nginx.conf COPY nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf -COPY static /var/www/static # Set proper permissions for static files -RUN chown -R nginx:nginx /var/www/static +RUN mkdir -p /var/www/static && chown -R nginx:nginx /var/www/static # Expose ports EXPOSE 80 diff --git a/Dockerfile.nginx.static b/Dockerfile.nginx.static deleted file mode 100644 index 7cecd1d..0000000 --- a/Dockerfile.nginx.static +++ /dev/null @@ -1,24 +0,0 @@ -# Use an official Nginx image as a parent image -FROM nginx:1.21-alpine - -# Remove default Nginx configuration -RUN rm /etc/nginx/conf.d/default.conf - -# Copy custom Nginx configuration -COPY nginx/nginx.conf /etc/nginx/nginx.conf -COPY nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf - -# Copy static files -COPY static /var/www/static - -# Set the ownership of the static files directory to nginx user -RUN chown -R nginx:nginx /var/www/static - -# Expose port 80 for HTTP traffic -EXPOSE 80 - -# Expose port 443 for HTTPS traffic -EXPOSE 443 - -# Command to start Nginx -CMD ["nginx", "-g", "daemon off;"] diff --git a/app/Dockerfile b/app/Dockerfile deleted file mode 100644 index 62f5531..0000000 --- a/app/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.11-slim-buster - -WORKDIR /app - -# Upgrade pip -RUN pip install --upgrade pip - -# Install dependencies -COPY requirements.txt . -RUN pip install -r requirements.txt - -# Copy project files -COPY . . - -# Set up user -RUN groupadd -r appgroup && useradd -r -g appgroup appuser -RUN chown -R appuser:appgroup /app - -USER appuser - -# Expose port -EXPOSE 8000 - -# Command to run the application -CMD ["gunicorn", "techblog_cms.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/app/requirements.txt b/app/requirements.txt deleted file mode 100644 index b0b589c..0000000 --- a/app/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Django -gunicorn -psycopg2-binary -redis diff --git a/app/techblog_cms/__init__.py b/app/techblog_cms/__init__.py deleted file mode 100644 index f31d188..0000000 --- a/app/techblog_cms/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file is required to make this directory a Python package. \ No newline at end of file diff --git a/app/techblog_cms/migrations/0001_initial.py b/app/techblog_cms/migrations/0001_initial.py deleted file mode 100644 index 2bf8999..0000000 --- a/app/techblog_cms/migrations/0001_initial.py +++ /dev/null @@ -1,75 +0,0 @@ -# Generated by Django 4.2.10 on 2025-09-03 05:53 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="Category", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, unique=True)), - ("description", models.TextField(blank=True)), - ("slug", models.SlugField(blank=True, unique=True)), - ], - ), - migrations.CreateModel( - name="Tag", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=50, unique=True)), - ("slug", models.SlugField(blank=True, unique=True)), - ], - ), - migrations.CreateModel( - name="Article", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("title", models.CharField(max_length=200)), - ("content", models.TextField()), - ("slug", models.SlugField(blank=True, unique=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "category", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="articles", - to="techblog_cms.category", - ), - ), - ("tags", models.ManyToManyField(blank=True, to="techblog_cms.tag")), - ], - ), - ] diff --git a/app/techblog_cms/models.py b/app/techblog_cms/models.py deleted file mode 100644 index c78a7c1..0000000 --- a/app/techblog_cms/models.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.db import models -from django.utils.text import slugify - - -class Category(models.Model): - name = models.CharField(max_length=100, unique=True) - description = models.TextField(blank=True) - slug = models.SlugField(unique=True, blank=True) - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.name) - super().save(*args, **kwargs) - - def __str__(self) -> str: - return self.name - - -class Tag(models.Model): - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True, blank=True) - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.name) - super().save(*args, **kwargs) - - def __str__(self) -> str: - return self.name - - -class Article(models.Model): - title = models.CharField(max_length=200) - content = models.TextField() - slug = models.SlugField(unique=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - category = models.ForeignKey( - Category, on_delete=models.CASCADE, related_name="articles" - ) - tags = models.ManyToManyField(Tag, blank=True) - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.title) - super().save(*args, **kwargs) - - def __str__(self) -> str: - return self.title diff --git a/app/techblog_cms/settings.py b/app/techblog_cms/settings.py deleted file mode 100644 index 2005327..0000000 --- a/app/techblog_cms/settings.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -from pathlib import Path -from urllib.parse import urlparse - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - -# Security settings -SECRET_KEY = os.environ.get("SECRET_KEY") -DEBUG = os.environ.get("DEBUG", "False") == "True" -ALLOWED_HOSTS = [ - host.strip() - for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") -] - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "techblog_cms", # Add the techblog_cms application -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "techblog_cms.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [BASE_DIR / "templates"], - "APP_DIRS": True, - "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 = "techblog_cms.wsgi.application" - -# Database configuration -DATABASE_URL = os.environ.get("DATABASE_URL") -if DATABASE_URL: - parsed_url = urlparse(DATABASE_URL) - DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": (parsed_url.path or "/")[1:], - "USER": parsed_url.username, - "PASSWORD": parsed_url.password, - "HOST": parsed_url.hostname, - "PORT": str(parsed_url.port or "5432"), - } - } -else: - DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("DB_NAME", "postgres"), - "USER": os.environ.get("DB_USER", "postgres"), - "PASSWORD": os.environ.get("DB_PASSWORD", ""), - "HOST": os.environ.get("DB_HOST", "db"), # docker-compose を想定 - "PORT": os.environ.get("DB_PORT", "5432"), - } - } - -# Static files -STATIC_URL = "/static/" -STATIC_ROOT = BASE_DIR.parent / "static" - -# Security options -SECURE_HSTS_SECONDS = int(os.environ.get("SECURE_HSTS_SECONDS", "0")) -SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "False") == "True" -CSRF_COOKIE_SECURE = os.environ.get("CSRF_COOKIE_SECURE", "False") == "True" \ No newline at end of file diff --git a/app/techblog_cms/urls.py b/app/techblog_cms/urls.py deleted file mode 100644 index e9df018..0000000 --- a/app/techblog_cms/urls.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.urls import path - -urlpatterns = [ - # Define your URL patterns here -] diff --git a/app/techblog_cms/views.py b/app/techblog_cms/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/app/techblog_cms/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/app/techblog_cms/wsgi.py b/app/techblog_cms/wsgi.py deleted file mode 100644 index e38eec6..0000000 --- a/app/techblog_cms/wsgi.py +++ /dev/null @@ -1,6 +0,0 @@ -import os -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'techblog_cms.settings') - -application = get_wsgi_application() diff --git a/assitant.agent.prompt.md b/assitant.agent.prompt.md index a6fb9fb..6bf2c3e 100644 --- a/assitant.agent.prompt.md +++ b/assitant.agent.prompt.md @@ -50,7 +50,7 @@ model: Grok Code Fast 1 (Preview) 2. **Docker設定**: - **Djangoコンテナ (Dockerfile.django)**: Python 3.11ベースで依存関係をインストールし、非rootユーザーで実行。 - - **Nginxコンテナ (Dockerfile.nginx, Dockerfile.nginx.static)**: 静的ファイル配信とリバースプロキシ。SSL証明書をマウント。 + - **Nginxコンテナ (Dockerfile.nginx)**: 静的ファイル配信とリバースプロキシ。SSL証明書をマウント。 - **Compose (docker-compose.yml)**: サービス間ネットワーク、ボリューム、環境変数を定義。Certbotで証明書更新を自動化。 3. **データベースとキャッシュ**: @@ -87,4 +87,4 @@ AGENTS.mdファイルを作成しました。このファイルには、レポ - 貢献プロセス このファイルは今後、プロジェクトの変更に合わせて更新していくことをおすすめします。 - \ No newline at end of file + diff --git a/create_sample_data.py b/create_sample_data.py deleted file mode 100644 index b2c54d4..0000000 --- a/create_sample_data.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python -import os -import sys -import django - -# Django設定を読み込む -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'techblog_cms.settings') -django.setup() - -from techblog_cms.models import Category, Article - -# カテゴリを作成 -tech_category, created = Category.objects.get_or_create( - name='Technology', - defaults={'description': 'Latest technology news and trends'} -) - -programming_category, created = Category.objects.get_or_create( - name='Programming', - defaults={'description': 'Programming tutorials and tips'} -) - -# 記事を作成 -articles_data = [ - { - 'title': 'Introduction to Python', - 'content': 'Python is a high-level programming language known for its simplicity and readability. It is widely used for web development, data analysis, artificial intelligence, and more.', - 'category': programming_category, - 'published': True - }, - { - 'title': 'The Future of AI', - 'content': 'Artificial Intelligence is rapidly evolving and transforming various industries. From machine learning to natural language processing, AI is becoming an integral part of our daily lives.', - 'category': tech_category, - 'published': True - }, - { - 'title': 'Web Development Best Practices', - 'content': 'Modern web development requires following best practices to create scalable, maintainable, and user-friendly applications. This includes proper code organization, security measures, and performance optimization.', - 'category': programming_category, - 'published': True - }, - { - 'title': 'Cloud Computing Trends', - 'content': 'Cloud computing continues to grow as businesses move their infrastructure to the cloud. Understanding the latest trends in cloud technology is essential for staying competitive.', - 'category': tech_category, - 'published': True - } -] - -for article_data in articles_data: - article, created = Article.objects.get_or_create( - title=article_data['title'], - defaults={ - 'content': article_data['content'], - 'category': article_data['category'], - 'published': article_data['published'] - } - ) - if created: - print(f"Created article: {article.title}") - else: - print(f"Article already exists: {article.title}") - -print("Test data creation completed!") diff --git a/docker-compose.yml b/docker-compose.yml index c337d51..c00156c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -129,23 +129,6 @@ services: # Security: Requires a password for authentication. # Consider network restrictions for enhanced security. - # Static Files Server - static: - image: nginx:alpine - volumes: - - static_volume:/usr/share/nginx/html - ports: - - "8080:80" - networks: - - techblog_network - restart: unless-stopped - user: root - deploy: - resources: - limits: - cpus: "0.25" - memory: 128M - certbot: image: certbot/certbot:latest volumes: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 03acde5..f2117d8 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -39,7 +39,7 @@ echo "Checking for pending model migrations..." python manage.py makemigrations --check --dry-run echo "Applying migrations..." -python manage.py migrate --noinput +python manage.py migrate --noinput --fake-initial # ------------------------------------------- # 開発環境の場合はマイグレーションファイルの作成とテストデータの作成 diff --git a/docs/QUICK_CONFIG_REFERENCE.md b/docs/QUICK_CONFIG_REFERENCE.md index ce4d45f..3cb16f7 100644 --- a/docs/QUICK_CONFIG_REFERENCE.md +++ b/docs/QUICK_CONFIG_REFERENCE.md @@ -86,7 +86,6 @@ techblog_cms/ ├── docker-compose.override.yml # Development overrides (git ignored) ├── techblog_cms/ # Django application │ ├── settings.py # Main settings -│ ├── settings_production.py # Production overrides │ └── management/ # Custom commands ├── nginx/ # Web server config │ ├── conf.d/ # Site configurations @@ -125,4 +124,4 @@ techblog_cms/ 4. **Application errors** - Check logs: `docker-compose logs django` - - Verify migrations: `docker-compose exec django python manage.py showmigrations` \ No newline at end of file + - Verify migrations: `docker-compose exec django python manage.py showmigrations` diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 32635a4..3c877a2 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -1,4 +1,3 @@ -import multiprocessing import os # Get the directory containing this file @@ -6,7 +5,7 @@ # Basic configurations bind = "0.0.0.0:8000" -workers = multiprocessing.cpu_count() * 2 + 1 +workers = int(os.environ.get('GUNICORN_WORKERS', '3')) worker_class = "sync" # Changed from gevent to sync for stability timeout = 120 keepalive = 5 @@ -19,9 +18,9 @@ # Logging errorlog = os.path.join(current_dir, 'logs/error.log') accesslog = os.path.join(current_dir, 'logs/access.log') -loglevel = 'debug' +loglevel = os.environ.get('GUNICORN_LOG_LEVEL', 'info') # Development settings -reload = True +reload = os.environ.get('DJANGO_ENV') == 'development' capture_output = True enable_stdio_inheritance = True diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 8f938f1..0000000 --- a/nginx.conf +++ /dev/null @@ -1,47 +0,0 @@ -# Define the upstream server (Django application). -upstream django { - server django:8000; -} - -# Configuration for the HTTP server (redirect to HTTPS). -server { - listen 80; - server_name yourdomain.com; # Replace with your domain - - # Redirect all HTTP requests to HTTPS. - return 301 https://$host$request_uri; -} - -# Configuration for the HTTPS server. -server { - listen 443 ssl; - server_name yourdomain.com; # Replace with your domain - - # SSL/TLS configuration. Use self-signed certificates for testing. - ssl_certificate /etc/nginx/ssl/nginx.crt; - ssl_certificate_key /etc/nginx/ssl/nginx.key; - - # Security headers. - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-Content-Type-Options "nosniff"; - add_header X-XSS-Protection "1; mode=block"; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; - - # Access and error logs. - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; - - # Static files configuration. - location /static/ { - alias /var/www/static/; - } - - # Pass all non-static file requests to the Django server. - location / { - proxy_pass http://django; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index 40b5fa6..0000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM nginx:alpine - -# Install dependencies and create nginx user/group -RUN apk add --no-cache curl \ - && addgroup -S nginx \ - && adduser -S -G nginx nginx - -# Set working directory -WORKDIR /usr/share/nginx/html - -# Copy custom Nginx configuration -COPY ./nginx.conf /etc/nginx/nginx.conf -COPY ./default.conf /etc/nginx/conf.d/default.conf - -# Copy static files -COPY ./public . - -# Set \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index bad5ed5..5905936 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -71,7 +71,7 @@ server { proxy_buffer_size 16k; proxy_buffers 4 16k; client_max_body_size 10M; - proxy_buffering off; + proxy_buffering on; proxy_connect_timeout 300s; proxy_read_timeout 300s; proxy_send_timeout 300s; @@ -114,7 +114,7 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; - proxy_buffering off; + proxy_buffering on; proxy_connect_timeout 300s; proxy_read_timeout 300s; } diff --git a/nginx/nginx.conf b/nginx/nginx.conf index d44325a..da86a1b 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -20,5 +20,10 @@ http { sendfile on; keepalive_timeout 65; + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + include /etc/nginx/conf.d/*.conf; } diff --git a/requirements.txt b/requirements.txt index 96b047f..9bbec63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # Core dependencies -Django==4.2.10 +Django==4.2.30 python-decouple>=3.8,<4.0 -Pillow>=10.0,<11.0 +Pillow>=12.1.1,<13.0 Pygments>=2.15,<3.0 # Database @@ -12,23 +12,11 @@ redis>=5.0,<6.0 django-redis>=5.4,<6.0 # Web server -gunicorn>=21.2,<22.0 +gunicorn>=23.0,<24.0 # HTTP client (for healthcheck) requests>=2.31,<3.0 -# Production optimizations -whitenoise>=6.5,<7.0 # Static file serving -django-compressor>=4.4,<5.0 # CSS/JS compression - -# Security -django-cors-headers>=4.3,<5.0 -django-csp>=3.7,<4.0 # Content Security Policy - -# Monitoring and debugging (production) -sentry-sdk>=1.39,<2.0 -django-extensions>=3.2,<4.0 - # Testing pytest>=7.4,<8.0 pytest-django>=4.7,<5.0 @@ -36,9 +24,6 @@ pytest-cov>=4.1,<5.0 playwright>=1.40,<2.0 # Code quality -black>=23.12,<24.0 -flake8>=6.1,<7.0 -isort>=5.13,<6.0 # Documentation markdown>=3.5,<4.0 diff --git a/scripts/create_test_data.py b/scripts/create_test_data.py deleted file mode 100644 index 1039f10..0000000 --- a/scripts/create_test_data.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -import sys -from pathlib import Path - -import django - -BASE_DIR = Path(__file__).resolve().parent.parent -sys.path.append(str(BASE_DIR / "app")) - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "techblog_cms.settings") -django.setup() - -from techblog_cms.models import Category, Tag, Article - - -def create_initial_data(): - """Create sample categories and tags.""" - Category.objects.create( - name="Programming", description="Programming related articles" - ) - Category.objects.create( - name="Infrastructure", description="Infrastructure and DevOps" - ) - Category.objects.create(name="Design", description="UI/UX Design topics") - - Tag.objects.create(name="Python") - Tag.objects.create(name="Django") - Tag.objects.create(name="Docker") - - -if __name__ == "__main__": - create_initial_data() - print("Successfully created test data") diff --git a/scripts/production_checklist.sh b/scripts/production_checklist.sh index 50fccb7..4e37ae9 100755 --- a/scripts/production_checklist.sh +++ b/scripts/production_checklist.sh @@ -58,7 +58,9 @@ echo "" echo "2. Configuration Files" echo "----------------------" check "Environment file exists" "[ -f .env ]" -check "Production settings exist" "[ -f techblog_cms/settings_production.py ]" +check "Base settings exist" "[ -f techblog_cms/settings.py ]" +check "Persistent DB connections configured" "grep -q \"DATABASES\['default'\]\['CONN_MAX_AGE'\]\" techblog_cms/settings.py" +check "HSTS configured" "grep -q 'SECURE_HSTS_SECONDS = 31536000' techblog_cms/settings.py" check ".env is in .gitignore" "grep -q '^\.env$' .gitignore" echo "" @@ -109,4 +111,4 @@ else fi echo "=========================================" -exit $([ "$READY" = true ] && echo 0 || echo 1) \ No newline at end of file +exit $([ "$READY" = true ] && echo 0 || echo 1) diff --git a/techblog_cms/apps.py b/techblog_cms/apps.py new file mode 100644 index 0000000..1e01ff9 --- /dev/null +++ b/techblog_cms/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class TechblogCmsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'techblog_cms' + + def ready(self): + from . import signals # noqa: F401 diff --git a/techblog_cms/context_processors.py b/techblog_cms/context_processors.py index 4ee543c..0a0e71c 100644 --- a/techblog_cms/context_processors.py +++ b/techblog_cms/context_processors.py @@ -1,9 +1,33 @@ import os import sys +from django.core.cache import cache + +from .models import Category, Tag + + +SIDEBAR_CACHE_TTL = 300 + def testing_mode(request): """Add IS_TESTING variable to template context""" IS_TESTING = os.environ.get('TESTING') == 'True' or 'PYTEST_CURRENT_TEST' in os.environ or any( x.endswith('pytest') for x in sys.modules.keys() ) return {'IS_TESTING': IS_TESTING} + + +def sidebar(request): + categories = cache.get('sidebar:categories') + if categories is None: + categories = list(Category.objects.all()) + cache.set('sidebar:categories', categories, SIDEBAR_CACHE_TTL) + + tags = cache.get('sidebar:tags') + if tags is None: + tags = list(Tag.objects.all()) + cache.set('sidebar:tags', tags, SIDEBAR_CACHE_TTL) + + return { + 'categories': categories, + 'tags': tags, + } diff --git a/techblog_cms/import os.py b/techblog_cms/import os.py deleted file mode 100644 index c7aeb70..0000000 --- a/techblog_cms/import os.py +++ /dev/null @@ -1,14 +0,0 @@ -import os -from django.test import TestCase -from django.core.wsgi import get_wsgi_application - -# techblog_cms/test_wsgi.py - -class WsgiTests(TestCase): - def test_wsgi_application_creation(self): - # WSGIアプリケーションが正しくインスタンス化されるかテスト - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'techblog_cms.settings') - application = get_wsgi_application() - self.assertIsNotNone(application) - # アプリケーションが呼び出し可能か確認 - self.assertTrue(callable(application)) \ No newline at end of file diff --git a/techblog_cms/migrations/0001_initial.py b/techblog_cms/migrations/0001_initial.py new file mode 100644 index 0000000..c572545 --- /dev/null +++ b/techblog_cms/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.10 on 2026-07-18 13:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('slug', models.SlugField(unique=True)), + ('content', models.TextField()), + ('excerpt', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('published', models.BooleanField(default=False)), + ('image', models.ImageField(blank=True, null=True, upload_to='articles/')), + ], + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('slug', models.SlugField(unique=True)), + ('description', models.TextField(blank=True)), + ], + options={ + 'verbose_name_plural': 'categories', + }, + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('slug', models.SlugField(unique=True)), + ], + ), + migrations.CreateModel( + name='ArticleInlineImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='articles/')), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inline_images', to='techblog_cms.article')), + ], + options={ + 'ordering': ['uploaded_at', 'pk'], + }, + ), + migrations.AddField( + model_name='article', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='techblog_cms.category'), + ), + migrations.AddField( + model_name='article', + name='tags', + field=models.ManyToManyField(blank=True, to='techblog_cms.tag'), + ), + ] diff --git a/techblog_cms/migrations/0002_article_article_pub_created_idx.py b/techblog_cms/migrations/0002_article_article_pub_created_idx.py new file mode 100644 index 0000000..45de471 --- /dev/null +++ b/techblog_cms/migrations/0002_article_article_pub_created_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.10 on 2026-07-18 13:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('techblog_cms', '0001_initial'), + ] + + operations = [ + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['published', '-created_at'], name='article_pub_created_idx'), + ), + ] diff --git a/app/techblog_cms/migrations/__init__.py b/techblog_cms/migrations/__init__.py similarity index 100% rename from app/techblog_cms/migrations/__init__.py rename to techblog_cms/migrations/__init__.py diff --git a/techblog_cms/models.py b/techblog_cms/models.py index 7de7929..b99962e 100644 --- a/techblog_cms/models.py +++ b/techblog_cms/models.py @@ -90,6 +90,14 @@ def __str__(self): def get_absolute_url(self): return reverse('article_detail', kwargs={'slug': self.slug}) + class Meta: + indexes = [ + models.Index( + fields=['published', '-created_at'], + name='article_pub_created_idx', + ), + ] + class ArticleInlineImage(models.Model): article = models.ForeignKey(Article, related_name='inline_images', on_delete=models.CASCADE) diff --git a/techblog_cms/settings.py b/techblog_cms/settings.py index af4f2c9..331e7a9 100644 --- a/techblog_cms/settings.py +++ b/techblog_cms/settings.py @@ -24,13 +24,12 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'techblog_cms', # Add the techblog_cms application + 'techblog_cms.apps.TechblogCmsConfig', ] MIDDLEWARE = [ @@ -57,6 +56,7 @@ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'techblog_cms.context_processors.testing_mode', + 'techblog_cms.context_processors.sidebar', ], }, }, @@ -128,6 +128,20 @@ } } +DATABASES['default']['CONN_MAX_AGE'] = config('CONN_MAX_AGE', default=60, cast=int) + +if not DEBUG: + TEMPLATES[0]['APP_DIRS'] = False + TEMPLATES[0]['OPTIONS']['loaders'] = [ + ( + 'django.template.loaders.cached.Loader', + [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ], + ), + ] + # Static files (CSS, JavaScript, Images) STATIC_URL = 'static/' STATIC_ROOT = os.path.join(BASE_DIR, 'static') @@ -144,9 +158,7 @@ fmt.upper() for fmt in config('ARTICLE_IMAGE_ALLOWED_FORMATS', default='JPEG,PNG,GIF,WEBP', cast=Csv()) ) ARTICLE_IMAGE_MAX_PIXELS = config('ARTICLE_IMAGE_MAX_PIXELS', default=20_000_000, cast=int) - -# Admin hardening -HIDE_ADMIN_URL = True +ARTICLE_IMAGE_MAX_DIMENSION = config('ARTICLE_IMAGE_MAX_DIMENSION', default=1920, cast=int) # CSRF failure view for debugging CSRF_FAILURE_VIEW = 'django.views.csrf.csrf_failure' @@ -199,29 +211,37 @@ # Security Settings for Production if not DEBUG: SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=True, cast=bool) + SECURE_REDIRECT_EXEMPT = [r'^health/$', r'^ready/$'] SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=True, cast=bool) CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=True, cast=bool) - SECURE_BROWSER_XSS_FILTER = True SECURE_CONTENT_TYPE_NOSNIFF = True X_FRAME_OPTIONS = 'DENY' SECURE_HSTS_SECONDS = 31536000 SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + SESSION_COOKIE_HTTPONLY = True + CSRF_COOKIE_HTTPONLY = True # Cache Configuration if not IS_TESTING: + redis_url = config('REDIS_URL', default='redis://redis:6379/1') CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': config('REDIS_URL', default='redis://redis:6379/1'), + 'LOCATION': redis_url, 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'IGNORE_EXCEPTIONS': True, }, 'KEY_PREFIX': 'techblog', 'TIMEOUT': 300, } } + redis_password = config('REDIS_PASSWORD', default='') + parsed_redis_url = urlparse(redis_url) + if redis_password and parsed_redis_url.username is None and parsed_redis_url.password is None: + CACHES['default']['OPTIONS']['PASSWORD'] = redis_password # Session Configuration SESSION_ENGINE = 'django.contrib.sessions.backends.cache' @@ -291,10 +311,6 @@ }, } -# Media files configuration -MEDIA_URL = '/media/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') - # Email configuration (for production) if not DEBUG: EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' diff --git a/techblog_cms/settings_production.py b/techblog_cms/settings_production.py deleted file mode 100644 index 98b4ce0..0000000 --- a/techblog_cms/settings_production.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Production settings for Tech Blog CMS -""" -from .settings import * - -# Override settings for production -DEBUG = False - -# Security Settings - All should be True in production -SECURE_SSL_REDIRECT = True -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True -SECURE_BROWSER_XSS_FILTER = True -SECURE_CONTENT_TYPE_NOSNIFF = True -X_FRAME_OPTIONS = 'DENY' -SECURE_HSTS_SECONDS = 31536000 # 1 year -SECURE_HSTS_INCLUDE_SUBDOMAINS = True -SECURE_HSTS_PRELOAD = True -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') - -# Content Security Policy -CSP_DEFAULT_SRC = ("'self'",) -CSP_STYLE_SRC = ("'self'", "'unsafe-inline'", "https://fonts.googleapis.com") -CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'", "'unsafe-eval'") -CSP_FONT_SRC = ("'self'", "https://fonts.gstatic.com") -CSP_IMG_SRC = ("'self'", "data:", "https:") - -# SecurityMiddleware is already configured in base settings. -if 'django.middleware.security.SecurityMiddleware' not in MIDDLEWARE: - MIDDLEWARE.insert(0, 'django.middleware.security.SecurityMiddleware') - -# Force cookies to be httponly -SESSION_COOKIE_HTTPONLY = True -CSRF_COOKIE_HTTPONLY = True - -# Database connection pooling -DATABASES['default']['CONN_MAX_AGE'] = 60 - -# Disable debug toolbar in production -INTERNAL_IPS = [] - -# Use whitenoise for static files in production (optional) -# MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware') -# STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' - -# Production logging - only log warnings and above -LOGGING['root']['level'] = 'WARNING' -LOGGING['loggers']['django']['level'] = 'WARNING' -LOGGING['loggers']['techblog_cms']['level'] = 'INFO' - -# Sentry integration (optional) -# import sentry_sdk -# from sentry_sdk.integrations.django import DjangoIntegration -# -# sentry_sdk.init( -# dsn=config('SENTRY_DSN', default=''), -# integrations=[DjangoIntegration()], -# traces_sample_rate=0.1, -# send_default_pii=False -# ) \ No newline at end of file diff --git a/techblog_cms/signals.py b/techblog_cms/signals.py new file mode 100644 index 0000000..bfc728d --- /dev/null +++ b/techblog_cms/signals.py @@ -0,0 +1,15 @@ +from django.core.cache import cache +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .models import Category, Tag + + +@receiver([post_save, post_delete], sender=Category) +def invalidate_category_sidebar_cache(**kwargs): + cache.delete('sidebar:categories') + + +@receiver([post_save, post_delete], sender=Tag) +def invalidate_tag_sidebar_cache(**kwargs): + cache.delete('sidebar:tags') diff --git a/techblog_cms/static/js/articles.js b/techblog_cms/static/js/articles.js deleted file mode 100644 index 0b91199..0000000 --- a/techblog_cms/static/js/articles.js +++ /dev/null @@ -1,71 +0,0 @@ -let currentPage = 1; -const articlesPerPage = 6; -let articlesData = []; - -// Fetch articles function -async function fetchArticles(page) { - return new Promise((resolve) => { - setTimeout(() => { - const newArticles = Array.from({ length: articlesPerPage }, (_, i) => ({ - id: (page - 1) * articlesPerPage + i + 1, - title: `記事タイトル ${(page - 1) * articlesPerPage + i + 1}`, - excerpt: `これは記事 ${(page - 1) * articlesPerPage + i + 1} の概要です。`, - link: '#' - })); - resolve(newArticles); - }, 500); - }); -} - -// Render articles -function renderArticles(articles) { - const container = document.getElementById('articlesContainer'); - articles.forEach(article => { - const card = document.createElement('div'); - card.className = 'article-card bg-white p-4 rounded shadow hover:shadow-lg transition'; - card.innerHTML = ` -

${article.title}

-

${article.excerpt}

- 続きを読む - `; - container.appendChild(card); - }); -} - -// Search functionality -document.getElementById('searchBox')?.addEventListener('input', (e) => { - const query = e.target.value.toLowerCase(); - const filteredArticles = articlesData.filter(article => - article.title.toLowerCase().includes(query) || - article.excerpt.toLowerCase().includes(query) - ); - - const container = document.getElementById('articlesContainer'); - container.innerHTML = ''; - renderArticles(filteredArticles); -}); - -// Infinite scroll -const observer = new IntersectionObserver(async (entries) => { - if (entries[0].isIntersecting) { - const newArticles = await fetchArticles(currentPage); - articlesData = [...articlesData, ...newArticles]; - renderArticles(newArticles); - currentPage++; - } -}, { - root: null, - rootMargin: '100px', - threshold: 0.1 -}); - -// Start observation -document.addEventListener('DOMContentLoaded', () => { - const sentinel = document.getElementById('scrollSentinel'); - if (sentinel) observer.observe(sentinel); - fetchArticles(currentPage).then(articles => { - articlesData = articles; - renderArticles(articles); - currentPage++; - }); -}); diff --git a/techblog_cms/templates/article_detail.html b/techblog_cms/templates/article_detail.html index 924f862..71a165f 100644 --- a/techblog_cms/templates/article_detail.html +++ b/techblog_cms/templates/article_detail.html @@ -258,7 +258,7 @@

{{ article.title }}

{% endif %}
- {{ article.content|markdown_to_html }} + {{ content_html }}
diff --git a/techblog_cms/templates/article_list.html b/techblog_cms/templates/article_list.html index dd4e855..e8f07e5 100644 --- a/techblog_cms/templates/article_list.html +++ b/techblog_cms/templates/article_list.html @@ -24,5 +24,6 @@

No articles published yet.

{% endfor %} + {% include 'components/pagination.html' %} {% endblock %} diff --git a/techblog_cms/templates/category_detail.html b/techblog_cms/templates/category_detail.html index 1b57dc6..86dc319 100644 --- a/techblog_cms/templates/category_detail.html +++ b/techblog_cms/templates/category_detail.html @@ -26,6 +26,8 @@

{% endfor %} + {% include 'components/pagination.html' %} +
← Back to Categories
diff --git a/techblog_cms/templates/category_list.html b/techblog_cms/templates/category_list.html index 0bf9a79..cde994d 100644 --- a/techblog_cms/templates/category_list.html +++ b/techblog_cms/templates/category_list.html @@ -19,9 +19,7 @@

{{ category.description }}

{% endif %}
- {% with article_count=category.article_set.count %} - {{ article_count }} article{{ article_count|pluralize }} - {% endwith %} + {{ category.num_articles }} article{{ category.num_articles|pluralize }}
{% empty %} diff --git a/techblog_cms/templates/components/pagination.html b/techblog_cms/templates/components/pagination.html new file mode 100644 index 0000000..d1f0580 --- /dev/null +++ b/techblog_cms/templates/components/pagination.html @@ -0,0 +1,21 @@ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} diff --git a/techblog_cms/templates/index.html b/techblog_cms/templates/index.html deleted file mode 100644 index fbefe6f..0000000 --- a/techblog_cms/templates/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Tech Blog - - -

Welcome to Tech Blog

-

The site is up and running!

- - \ No newline at end of file diff --git a/techblog_cms/templates/tag_detail.html b/techblog_cms/templates/tag_detail.html index d4a849f..31ea770 100644 --- a/techblog_cms/templates/tag_detail.html +++ b/techblog_cms/templates/tag_detail.html @@ -7,7 +7,7 @@

#{{ tag.name }}

-

{{ articles|length }} 件の記事がこのタグに紐付いています。

+

{{ page_obj.paginator.count }} 件の記事がこのタグに紐付いています。

← タグ一覧に戻る
@@ -30,5 +30,6 @@

このタグが付いた記事はまだありません。

{% endfor %} + {% include 'components/pagination.html' %} {% endblock %} diff --git a/techblog_cms/templatetags/markdown_filter.py b/techblog_cms/templatetags/markdown_filter.py index 145699a..1b8ad4a 100644 --- a/techblog_cms/templatetags/markdown_filter.py +++ b/techblog_cms/templatetags/markdown_filter.py @@ -81,7 +81,7 @@ def markdown_to_html(text): html = markdown.markdown(text, extensions=extensions, extension_configs={ 'codehilite': { 'linenums': False, - 'guess_lang': True, + 'guess_lang': False, 'css_class': 'highlight', 'pygments_style': 'github-dark', } diff --git a/techblog_cms/tests/test_article_editing.py b/techblog_cms/tests/test_article_editing.py index baf120b..c3c68ec 100644 --- a/techblog_cms/tests/test_article_editing.py +++ b/techblog_cms/tests/test_article_editing.py @@ -125,6 +125,70 @@ def test_uploading_multiple_images_creates_inline_images(self): for attachment in attachments: self.assertContains(detail_response, attachment.image.url) + def test_inline_image_long_edge_is_resized(self): + self.client.login(username="editor", password="pass1234") + url = reverse("article_edit", args=[self.article.slug]) + large_image = self._make_image_file( + format="PNG", + size=(1200, 2400), + name="portrait.png", + ) + + response = self.client.post( + url, + { + "title": "Original Title", + "content": "Updated body with an inline image", + "action": "save", + "images": [large_image], + }, + ) + + self.assertEqual(response.status_code, 302) + attachment = ArticleInlineImage.objects.get(article=self.article) + self.assertEqual(attachment.filename, "portrait.png") + with Image.open(attachment.image.path) as saved_image: + self.assertEqual(saved_image.size, (960, 1920)) + self.assertEqual(saved_image.format, "PNG") + + def test_gif_upload_is_saved_without_reencoding(self): + self.client.login(username="editor", password="pass1234") + url = reverse("article_edit", args=[self.article.slug]) + buffer = io.BytesIO() + frames = [ + Image.new("RGB", (32, 32), (255, 0, 0)), + Image.new("RGB", (32, 32), (0, 0, 255)), + ] + frames[0].save( + buffer, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=100, + loop=0, + ) + original_bytes = buffer.getvalue() + animated_gif = SimpleUploadedFile( + "animation.gif", + original_bytes, + content_type="image/gif", + ) + + response = self.client.post( + url, + { + "title": "Original Title", + "content": "Updated body with an animation", + "action": "save", + "images": [animated_gif], + }, + ) + + self.assertEqual(response.status_code, 302) + attachment = ArticleInlineImage.objects.get(article=self.article) + with attachment.image.open("rb") as saved_gif: + self.assertEqual(saved_gif.read(), original_bytes) + def test_plain_text_upload_is_rejected(self): self.client.login(username="editor", password="pass1234") url = reverse("article_edit", args=[self.article.slug]) diff --git a/techblog_cms/tests/test_views.py b/techblog_cms/tests/test_views.py index d32a1b9..f994e55 100644 --- a/techblog_cms/tests/test_views.py +++ b/techblog_cms/tests/test_views.py @@ -1,5 +1,11 @@ +from unittest.mock import patch + +from django.core.cache import cache from django.test import TestCase from django.urls import reverse +from django.utils.safestring import mark_safe + +from techblog_cms.models import Article, Category, Tag class HomePageTests(TestCase): def test_home_page_status_code(self): @@ -10,3 +16,84 @@ def test_home_page_status_code(self): def test_home_page_content(self): response = self.client.get(reverse('home')) self.assertContains(response, 'Welcome to Tech Blog') + + +class SidebarContextProcessorTests(TestCase): + def setUp(self): + cache.clear() + + def test_home_sidebar_displays_cached_categories_and_tags(self): + category = Category.objects.create(name='Caching', slug='caching') + tag = Tag.objects.create(name='Redis', slug='redis') + + response = self.client.get(reverse('home')) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, category.name) + self.assertContains(response, tag.name) + self.assertIsInstance(response.context['categories'], list) + self.assertIsInstance(response.context['tags'], list) + + def test_sidebar_cache_is_invalidated_when_category_is_created(self): + self.client.get(reverse('home')) + category = Category.objects.create(name='Signals', slug='signals') + + response = self.client.get(reverse('home')) + + self.assertContains(response, category.name) + + +class PublicArticleViewTests(TestCase): + def setUp(self): + cache.clear() + self.category = Category.objects.create(name='Performance', slug='performance') + self.tag = Tag.objects.create(name='Django', slug='django') + for number in range(12): + article = Article.objects.create( + title=f'Published article {number}', + content=f'Content {number}', + category=self.category, + published=True, + ) + article.tags.add(self.tag) + + self.draft = Article.objects.create( + title='Unpublished draft', + content='Draft content', + category=self.category, + published=False, + ) + + def test_paginated_article_views_return_second_page(self): + urls = ( + reverse('article_list'), + reverse('category', args=[self.category.slug]), + reverse('tag', args=[self.tag.slug]), + ) + + for url in urls: + with self.subTest(url=url): + response = self.client.get(url, {'page': 2}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['page_obj'].number, 2) + self.assertEqual(len(response.context['articles']), 2) + + def test_category_list_count_includes_unpublished_articles(self): + response = self.client.get(reverse('categories')) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, '13 articles') + + def test_article_detail_renders_and_caches_content_html(self): + article = Article.objects.filter(published=True).first() + rendered_html = mark_safe('

Rendered content

') + + with patch('techblog_cms.views.markdown_to_html', return_value=rendered_html) as renderer: + first_response = self.client.get(reverse('article_detail', args=[article.slug])) + second_response = self.client.get(reverse('article_detail', args=[article.slug])) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(first_response.context['content_html'], rendered_html) + self.assertContains(first_response, rendered_html, html=True) + self.assertEqual(second_response.status_code, 200) + renderer.assert_called_once_with(article.content) diff --git a/techblog_cms/urls.py b/techblog_cms/urls.py index fe14dd2..3cad330 100644 --- a/techblog_cms/urls.py +++ b/techblog_cms/urls.py @@ -1,6 +1,4 @@ -from django.contrib import admin from django.urls import path, re_path -from django.views.generic import TemplateView from django.conf import settings from django.conf.urls.static import static from . import views @@ -21,7 +19,6 @@ re_path(r'^dashboard/articles/(?P[\w\-]+)/edit/$', views.article_editor_view, name='article_edit'), re_path(r'^dashboard/articles/(?P[\w\-]+)/delete/$', views.article_delete_view, name='article_delete'), path('dashboard/articles/delete/success/', views.article_delete_success_view, name='article_delete_success'), - path('api/health/', views.health_check, name='health_check'), path('api/preview_markdown/', views.preview_markdown_view, name='preview_markdown'), path('admin/', views.admin_guard, name='admin_guard'), # Health check endpoints (for container orchestration) @@ -29,9 +26,5 @@ path('ready/', ReadinessCheckView.as_view(), name='ready'), ] -# Adminを隠す: 内部からのみアクセス(後でreverse proxy側で制御) -if not getattr(settings, 'HIDE_ADMIN_URL', False): - urlpatterns.append(path('admin/', admin.site.urls)) - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/techblog_cms/views.py b/techblog_cms/views.py index accf7e4..dfcca22 100644 --- a/techblog_cms/views.py +++ b/techblog_cms/views.py @@ -1,132 +1,116 @@ +import io + from django.shortcuts import render, redirect, get_object_or_404 -from django.http import JsonResponse, HttpResponseForbidden +from django.http import JsonResponse from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from django.views.decorators.csrf import csrf_exempt from django.core.paginator import Paginator -from PIL import Image, UnidentifiedImageError +from django.core.cache import cache +from django.core.files.base import ContentFile +from django.db.models import Count +from django.utils.safestring import mark_safe +from PIL import Image, ImageOps, UnidentifiedImageError from .models import Article, Category, Tag, ArticleInlineImage from techblog_cms.templatetags.markdown_filter import markdown_to_html from django.conf import settings from django.http import HttpResponseNotFound -def health_check(request): - return JsonResponse({"status": "ok"}) - -def index(request): - return render(request, 'index.html') - def home_view(request): - articles = Article.objects.filter(published=True).order_by('-created_at')[:10] - categories = Category.objects.all() - tags = Tag.objects.all() + articles = Article.objects.filter(published=True).select_related('category').order_by('-created_at')[:10] return render( request, 'home.html', { 'articles': articles, - 'categories': categories, - 'tags': tags, }, ) def article_list_view(request): - articles = Article.objects.filter(published=True).order_by('-created_at') - categories = Category.objects.all() - tags = Tag.objects.all() + articles = Article.objects.filter(published=True).select_related('category').order_by('-created_at') + paginator = Paginator(articles, 10) + page_obj = paginator.get_page(request.GET.get('page', 1)) return render( request, 'article_list.html', { - 'articles': articles, - 'categories': categories, - 'tags': tags, + 'articles': page_obj, + 'page_obj': page_obj, }, ) def categories_view(request): - categories = Category.objects.all() - tags = Tag.objects.all() + categories = Category.objects.annotate(num_articles=Count('article')) return render( request, 'category_list.html', { 'categories': categories, - 'tags': tags, }, ) def category_view(request, slug): category = get_object_or_404(Category, slug=slug) - articles = category.article_set.filter(published=True).order_by('-created_at') - categories = Category.objects.all() - tags = Tag.objects.all() + articles = category.article_set.filter(published=True).select_related('category').order_by('-created_at') + paginator = Paginator(articles, 10) + page_obj = paginator.get_page(request.GET.get('page', 1)) return render( request, 'category_detail.html', { 'category': category, - 'articles': articles, - 'categories': categories, - 'tags': tags, + 'articles': page_obj, + 'page_obj': page_obj, }, ) def tags_view(request): - tags = Tag.objects.all() - categories = Category.objects.all() - return render( - request, - 'tag_list.html', - { - 'tags': tags, - 'categories': categories, - }, - ) + return render(request, 'tag_list.html') def tag_view(request, slug): tag = get_object_or_404(Tag, slug=slug) if request.user.is_authenticated: - articles = tag.article_set.order_by('-created_at') + articles = tag.article_set.select_related('category').order_by('-created_at') else: - articles = tag.article_set.filter(published=True).order_by('-created_at') + articles = tag.article_set.filter(published=True).select_related('category').order_by('-created_at') + + paginator = Paginator(articles, 10) + page_obj = paginator.get_page(request.GET.get('page', 1)) - categories = Category.objects.all() - tags = Tag.objects.all() return render( request, 'tag_detail.html', { 'tag': tag, - 'articles': articles, - 'categories': categories, - 'tags': tags, + 'articles': page_obj, + 'page_obj': page_obj, }, ) def article_detail_view(request, slug): + articles = Article.objects.select_related('category').prefetch_related('tags') # ログインしている場合は下書き記事も表示可能 if request.user.is_authenticated: - article = get_object_or_404(Article, slug=slug) + article = get_object_or_404(articles, slug=slug) else: - article = get_object_or_404(Article, slug=slug, published=True) - categories = Category.objects.all() - tags = Tag.objects.all() + article = get_object_or_404(articles, slug=slug, published=True) + cache_key = f'article_html:{article.pk}:{article.updated_at.isoformat()}' + content_html = cache.get(cache_key) + if content_html is None: + content_html = str(markdown_to_html(article.content)) + cache.set(cache_key, content_html, timeout=60 * 60 * 24) return render( request, 'article_detail.html', { 'article': article, - 'categories': categories, - 'tags': tags, + 'content_html': mark_safe(content_html), }, ) def admin_guard(request): - """Direct /admin/ access guard. Show 404 if HIDE_ADMIN_URL is True.""" - if getattr(settings, 'HIDE_ADMIN_URL', False): - return HttpResponseNotFound('

Not Found

') - return redirect('/admin/') + """Return 404 for all direct /admin/ access.""" + return HttpResponseNotFound('

Not Found

') # Create your views here. @@ -208,6 +192,35 @@ def validate_article_image(uploaded_file): return uploaded_file, None +def process_article_image(uploaded_file): + """Normalize a validated article image while preserving its filename.""" + if not uploaded_file: + return None + + original_name = uploaded_file.name + uploaded_file.seek(0) + + with Image.open(uploaded_file) as image: + image_format = (image.format or '').upper() + if image_format == 'GIF': + uploaded_file.seek(0) + return uploaded_file + + processed_image = ImageOps.exif_transpose(image) + max_dimension = settings.ARTICLE_IMAGE_MAX_DIMENSION + if max_dimension and max(processed_image.size) > max_dimension: + processed_image.thumbnail( + (max_dimension, max_dimension), + Image.Resampling.LANCZOS, + ) + + output = io.BytesIO() + save_options = {'quality': 85} if image_format == 'JPEG' else {} + processed_image.save(output, format=image_format, **save_options) + + return ContentFile(output.getvalue(), name=original_name) + + @require_http_methods(["GET", "POST"]) @csrf_exempt def login_view(request): @@ -321,7 +334,7 @@ def render_editor(title_value, content_value, error=None, image_error=None): display_name = getattr(uploaded_image, 'name', '選択した画像') return render_editor(title, content, image_error=f"{display_name}: {image_error}") if cleaned_image: - cleaned_images.append(cleaned_image) + cleaned_images.append(process_article_image(cleaned_image)) if not title or not content: return render_editor(title, content, error="タイトルと本文は必須です。")