diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1942771 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# PostgreSQL 데이터베이스 설정 +# 팀원들이 각자 자신의 PostgreSQL 사용자명과 설정에 맞게 수정해야 합니다 +DB_ENGINE=django.db.backends.postgresql +DB_NAME=kitup +DB_USER=your_postgres_user # 수정 필요: 자신의 PostgreSQL 사용자명으로 변경 +DB_PASSWORD= # 수정 필요: PostgreSQL 비밀번호 입력 (없으면 비워두기) +DB_HOST=localhost +DB_PORT=5432 + +# Django 설정 +SECRET_KEY=your-secret-key-here # 수정 필요: 실제 SECRET_KEY로 변경 +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Allauth 설정 +SITE_ID=1 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9cc5ec7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ +--- +name: 🐛 Bug Report +about: 기능 오류, API 에러, 로직 이상 등 버그 제보 +title: "[Bug] " +labels: bug +assignees: "" +--- + +## 📌 버그 설명 +무엇이 잘못 동작하는지 간단히 작성 + +## 📍 발생 위치 +API: `` + +## 🔁 재현 방법 +1. +2. +3. + +## ❗ 실제 결과 +에러 메시지 또는 로그 + +## ✅ 기대 결과 +정상 동작 설명 diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..cca7920 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,18 @@ +--- +name: Feature +about: 새로운 기능 추가 / 개선 +title: "[Feat] " +labels: ["feature"] +assignees: [] +--- + +## 📌 작업 범위 + +- [ ] + +## ✅ TODO + +- [ ] + +## 📝 참고 사항 + \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d64db49 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## 🔥 작업 내용 + + +## 🔗 연관 이슈 + +- resolves # + +## ⚠️ 참고 사항 + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7229b30 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,66 @@ +name: Deploy to EC2 with GHCR + +on: + push: + branches: + - develop + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ghcr.io/pirogramming/startlinedev/web:latest + # 빌드 시 캐시로 인한 0바이트 파일 생성을 방지하고 싶다면 아래 옵션을 추가할 수 있습니다. + # no-cache: true + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Deploy to EC2 via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd ~/kitup + # 서버에서도 GHCR 이미지를 받을 수 있게 로그인 + echo ${{ secrets.GHCR_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + # 1. 기존의 태그 이미지(Dangling Images)를 정리하여 용량 확보 및 이미지 꼬임 방지 + docker image prune -f + + # 2. 최신 이미지 가져오기 + docker-compose pull web + + # 3. 컨테이너 재실행 + docker-compose up -d web + + # 4. 배포 직후 다시 한번 정리 (선택사항이지만 서버를 깨끗하게 유지해줍니다) + docker image prune -f + + # 5. 후처리 작업 (DB 동기화 및 정적파일 수집) + # 컨테이너가 뜰 시간을 잠깐 주기 위해 sleep을 넣는 것도 안정적입니다. + sleep 3 + docker-compose exec -T web python manage.py migrate + docker-compose exec -T web python manage.py collectstatic --noinput \ No newline at end of file diff --git a/.gitignore b/.gitignore index e5d8fad..fc055a5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ local_settings.py settings_local.py # Django media & static (선택) -media/ staticfiles/ ############################ @@ -35,15 +34,10 @@ env/ ENV/ pip-wheel-metadata/ -############################ -# Django collectstatic -############################ -static/ - ############################ # Docker ############################ -docker-compose.override.yml +#docker-compose.override.yml *.tar *.log @@ -87,3 +81,11 @@ htmlcov/ # 기타 ############################ .cache/ +/media/ + + +*.pem +kitup-key.pem + +# AI용 프로젝트 압축 파일 +*.zip \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1db7b6d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# system dependencies +RUN apt-get update && apt-get install -y \ + postgresql-client \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# python deps +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# project files +COPY . . + +# static / media +RUN mkdir -p /app/staticfiles /app/media + +EXPOSE 8000 + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/Dockerrun.aws.json b/Dockerrun.aws.json new file mode 100644 index 0000000..3416a5b --- /dev/null +++ b/Dockerrun.aws.json @@ -0,0 +1,12 @@ +{ + "AWSEBDockerrunVersion": "1", + "Image": { + "Name": "bimvocado/kitup-web:latest", + "Update": "true" + }, + "Ports": [ + { + "ContainerPort": 8000 + } + ] +} \ No newline at end of file diff --git a/FETCH_H b/FETCH_H new file mode 100644 index 0000000..e69de29 diff --git a/FETCH_HEAD b/FETCH_HEAD new file mode 100644 index 0000000..e69de29 diff --git a/FETCH_HLineDev b/FETCH_HLineDev new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index a498346..359fa88 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,190 @@ -# StartLineDev -개발 입문자를 위한 개인화 학습 가이드 및 프로젝트 연결 플랫폼 +# 🪁 KITUP + +개발 기간 : 2026.01.26 - 2026.02.19 + +
+ +> **개발자 팀 프로젝트가 끝까지 완주되도록 돕는 레벨·열정 기반 협업 매칭 플랫폼** + +기존 팀매칭 서비스가 팀을 연결하는 데에만 집중했다면, **KITUP**은 팀이 끝까지 완주할 수 있는 구조와 기준을 제공합니다. + +사용자의 실력 레벨과 열정 레벨을 기준으로 팀을 매칭하고, 프로젝트 진행 과정에서 활동·기록·회고가 자연스럽게 남도록 설계되었습니다. + +
+ +### **KITUP url : https://kitup.duckdns.org** + +
+
+ +# 📌 목차 +- [❓ 왜 KITUP인가?](#-왜-kitup인가) +- [🎯 주요 기능](#-주요-기능) +- [🏗 프로젝트 구조](#-프로젝트-구조) +- [🛠 기술 스택](#-기술-스택) +- [🎯 대상 이용자](#-대상-이용자) +- [💪🏻 팀원 구성](#-팀원-구성) + +
+ +# ❓ 왜 KITUP인가? + +기존 팀매칭 서비스는 팀을 **'연결'해주는 역할**에만 집중하며, 팀 프로젝트가 실패하는 핵심 원인을 해결하지 못합니다. + +
+ +| 문제 | 설명 | +|---|---| +| 🎯 **실력·목표 불일치** | 기술 스택 중심 매칭으로 실제 협업 가능 수준 판단이 어려움 | +| 🚪 **책임 구조 부재** | 무임승차, 중도 이탈, 소수 인원의 과도한 부담 | +| 📝 **기록이 남지 않음** | 협업 중 고민, 역할, 기여도가 구조적으로 저장되지 않음 | +| 🔒 **높은 진입장벽** | 입문자는 팀플 경험과 진행 방법 부족으로 참여 자체가 어려움 | + +
+ +**KITUP**은 이 모든 문제를 구조적으로 해결합니다. + +
+ +# 🎯 주요 기능 + +### 1️⃣ 실력 레벨 + 열정 레벨 기반 팀 매칭 +- 가입 시 설문 기반 실력 레벨 진단 (1~4단계) +- 팀플 신청 시 열정 레벨 측정 (주당 투자 시간, 목표 수준 기반) +- 동일/인접 레벨 간 매칭으로 불일치 최소화 + - 같은 팀 내 열정 차이 ≤ 1 + - 같은 팀 내 실력 차이 ≤ 1 +- 분야별(FE / BE / PM) 실력 레벨 분리 관리 + +### 2️⃣ 시즌 기반 프로젝트 운영 +- **팀매칭 기간** + **프로젝트 기간** +- 시즌 단위 팀 구성 및 프로젝트 관리 +- 팀 프로젝트 관리 페이지 제공 + +### 3️⃣ 입문자 전용 팀플 가이드 +- 레벨별 팀플 진행 순서 안내 +- MVP 볼륨 추천, 역할 분배 가이드 +- **체크리스트·미션 형식**으로 퀘스트를 깨듯 진행 + +### 4️⃣ 회고 및 활동 기록 +- 마크다운 기반 **회고 가이드** 제공 +- 프로젝트 종료 후 기록 자동 저장 → 협업 경험이 자산으로 축적 + +### 5️⃣ 마이페이지 및 성장 관리 +- 기술 스택별 실력 레벨 표기 및 재진단 +- 참여한 팀플 이력 및 상세 기록 열람 +- 프로필 관리 기능 + +
+ +# 🏗 프로젝트 구조 + +``` +KitUp/ +├── apps/ +│ ├── accounts/ # 회원 인증, 프로필, 기술 스택, 레벨 관리 +│ ├── guides/ # 팀플 가이드, 미션 카드, 체크리스트 +│ ├── projects/ # 시즌 관리, 팀 프로젝트 운영, 매칭 알고리즘 +│ ├── reflections/ # 회고 작성, 활동 기록, 피드백 +│ ├── teams/ # 팀 매칭, 팀 생성/관리 +├── config/ # Django 설정 +├── static/ # 정적 파일 (CSS, JS, 이미지) +├── templates/ # HTML 템플릿 +``` + +
+ +### 📦 앱 상세 + +
+ +| 앱 | 기능 | +|---|---| +| **accounts** | 회원가입, 로그인(allauth), 실력 레벨 진단, 기술 스택 관리, 프로필 | +| **guides** | 역할별(FE/BE/PM) 가이드 카드, 체크리스트 | +| **projects** | 시즌 생성/관리, 팀매칭 기간·프로젝트 기간 설정, 팀 프로젝트, 인접 레벨 매칭 알고리즘 | +| **reflections** | 마크다운 회고 작성, 프로젝트 기록 저장 | +| **teams** | 열정 레벨 기반 팀 매칭, 팀원 모집·지원, 팀 관리 | + +
+ +
+ +### 📄 주요 페이지 + +
+ +| 페이지 | 설명 | +|---|---| +| **메인 페이지** | 서비스 소개 및 시즌 안내 | +| **회원가입 / 실력 레벨 진단** | 가입 후 설문 기반 실력 레벨(1~4) 측정 | +| **팀플 탐색 및 신청 / 열정 레벨 진단** | 팀 찾기, 신청 시 열정 레벨 설문 | +| **팀 프로젝트 관리** | 프로젝트 개요, 팀원 정보, 팀플 가이드 | +| **회고 / 기록** | 회고 작성, 기록 저장 | +| **마이페이지** | 프로필 편집, 실력 레벨 확인/재진단, 팀플 이력 | + +
+ +
+ +# 🛠 기술 스택 + +### Stacks +
+ +| 구분 | Stack | +| :------: | :------: | +| **FE** | ![HTML5](https://img.shields.io/badge/html5-%23E34F26.svg?style=for-the-badge&logo=html5&logoColor=white) ![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) ![CSS3](https://img.shields.io/badge/css3-%231572B6.svg?style=for-the-badge&logo=css3&logoColor=white) | +| **BE** | ![Python](https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54) ![Django](https://img.shields.io/badge/django-%23092E20.svg?style=for-the-badge&logo=django&logoColor=white) ![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) | +| **SERVER** | ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?style=for-the-badge&logo=amazon-aws&logoColor=white) | + +
+ +
+ +### Tools +
+ +![Figma](https://img.shields.io/badge/figma-%23F24E1E.svg?style=for-the-badge&logo=figma&logoColor=white) ![Visual Studio Code](https://img.shields.io/badge/Visual%20Studio%20Code-0078d7.svg?style=for-the-badge&logo=visual-studio-code&logoColor=white) +
+ +### Collaboration +
+ +![Notion](https://img.shields.io/badge/Notion-%23000000.svg?style=for-the-badge&logo=notion&logoColor=white) ![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white) ![GitHub](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white) + +
+ +
+ +# 🎯 대상 이용자 + +
+ +| 대상 | 설명 | +|---|---| +| 🌱 **개발 입문자** | 팀플 경험이 없거나 부족한 사용자 | +| 🔄 **재도전자** | 사이드 프로젝트를 하고 싶지만 팀이 자주 무너졌던 사용자 | +| 📋 **기록자** | 협업 경험을 체계적으로 남기고 싶은 사용자 | +| 🎯 **목표 지향형** | 명확한 기간과 목표를 가지고 완주하고 싶은 사용자 | + +
+ +
+ +# 💪🏻 팀원 구성 + +
+ +| **성유리** | **장민지** | **김민하** | **이수종** | **이형주** | +| :------: | :------: | :------: | :------: | :------: | +| [
@bimvocado](https://github.com/bimvocado) | [
@plumbestie](https://github.com/plumbestie) | [
@knana6](https://github.com/knana6) | [
@issuejong](https://github.com/issuejong) | [
@Tonyjoo11](https://github.com/Tonyjoo11) | +| PM / BE | FE | FE | BE | BE | + +
+ +
+ +**© 2026 KITUP. All rights reserved.** + +
diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..4278935 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,265 @@ +# 🚀 StartLine Dev 개발 환경 설정 가이드 + +## 필수 사항 + +- **Python 3.13+** +- **PostgreSQL 14+** +- **Git** + +--- + +## 📋 팀원 설정 방법 (모두 동일) + +### 1️⃣ 프로젝트 클론 + +```bash +git clone +cd StartLineDev +``` + +### 2️⃣ 가상환경 생성 및 활성화 + +```bash +# 가상환경 생성 +python3 -m venv venv + +# 가상환경 활성화 +source venv/bin/activate # macOS/Linux +# 또는 +venv\Scripts\activate # Windows +``` + +### 3️⃣ 패키지 설치 + +```bash +pip install -r requirements.txt +``` + +### 4️⃣ PostgreSQL 설정 + +#### 4-1. PostgreSQL 설치 (처음 한 번만) + +**macOS:** +```bash +# Homebrew로 설치 +brew install postgresql + +# 서비스 시작 +brew services start postgresql +``` + +**Windows:** +- [PostgreSQL 공식 설치프로그램](https://www.postgresql.org/download/windows/) 다운로드 및 설치 + +**Linux (Ubuntu):** +```bash +sudo apt-get install postgresql postgresql-contrib +sudo service postgresql start +``` + +#### 4-2. 데이터베이스 생성 + +```bash +# PostgreSQL 접속 (자신의 PostgreSQL 사용자명으로) +# macOS 사용자 예시: +psql -U $(whoami) + +# Windows 사용자 예시: +psql -U postgres + +# 데이터베이스 생성 (모두 동일) +CREATE DATABASE startlinedev; + +# 확인 +\l + +# 나가기 +\q +``` + +**⚠️ 중요: 각 팀원이 자신의 PostgreSQL 사용자명을 사용해야 합니다.** +- macOS: 기본값은 설치된 맥 사용자명 (예: `isujong`, `john` 등) +- Windows: 기본값은 `postgres` (설치 중 설정한 비밀번호 필요) +- Linux: 기본값은 `postgres` + +### 5️⃣ 환경변수 설정 + +```bash +# .env 파일 생성 (프로젝트 루트) +cp .env.example .env +``` + +`.env` 파일 수정 (각 팀원이 자신의 정보로 수정): +```env +# 각자 자신의 PostgreSQL 사용자명 입력 +DB_ENGINE=django.db.backends.postgresql +DB_NAME=startlinedev # 모두 동일 +DB_USER=your_postgres_user # 자신의 PostgreSQL 사용자명으로 변경 +DB_PASSWORD=your_password # 자신의 PostgreSQL 비밀번호 +DB_HOST=localhost +DB_PORT=5432 + +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 +``` + +**예시:** +- macOS 사용자 (isujong): `DB_USER=isujong` +- Windows 사용자: `DB_USER=postgres` + +### 6️⃣ 마이그레이션 및 초기 데이터 + +```bash +# 마이그레이션 적용 +python manage.py migrate + +# 관리자 계정 생성 (처음 한 번만) +python manage.py createsuperuser +``` + +### 7️⃣ 서버 실행 + +```bash +python manage.py runserver +``` + +브라우저에서 확인: +- 메인 페이지: `http://localhost:8000` +- 관리자 페이지: `http://localhost:8000/admin` + +--- + +## 🗄️ 데이터베이스 확인 +# 자신의 PostgreSQL 사용자명으로 접속 +psql -d startlinedev -U your_postgres_user +### Django Admin (추천) +``` +http://localhost:8000/admin +``` + +### PostgreSQL 커맨드라인 +```bash +psql -d startlinedev -U isujong + +# 테이블 목록 +\dt + +# 특정 테이블 조회 +SELECT * FROM accounts_user; +SELECT * FROM learning_learningresource; + +# 나가기 +\q +``` + +### Django Shell +```bash +python manage.py shell + +from accounts.models import User +User.objects.all() +``` + +--- + +## 📦 필수 패키지 목록 + +현재 `requirements.txt`에 포함된 패키지: + +``` +Django==5.2.10 # Django 프레임워크 +django-allauth==65.14.0 # 사용자 인증 +psycopg2-binary==2.9.10 # PostgreSQL 드라이버 +pillow==12.1.0 # 이미지 처리 +``` + +--- + +## ⚠️ 일반적인 문제 해결 + +### "role 'isujong' does not exist" +```bash +# 현재 사용자 확인 +whoami + +# PostgreSQL 사용자 생성 (필요시) +createuser -s isujong +``` + +### "database 'startlinedev' does not exist" +```bash +# 데이터베이스 생성 +createdb startlinedev +``` + +### psycopg2 설치 오류 (macOS) +```bash +pip install psycopg2-binary +# 또는 +pip install --no-binary :all: psycopg2 +``` + +### 마이그레이션 충돌 +```bash +# 마이그레이션 상태 확인 +python manage.py showmigrations + +# 특정 마이그레이션 제거 (필요시) +python manage.py migrate +``` + +--- + +## 🔐 보안 주의사항 + +⚠️ **절대 커밋하지 말기:** +- `.env` 파일 (`.gitignore`에 포함) +- `db.sqlite3` +- `local_settings.py` +- `venv/` 디렉토리 + +✅ **공유할 파일:** +- `requirements.txt` (패키지 목록) +- `.env.example` (샘플 설정) +- `SETUP_GUIDE.md` (이 파일) + +--- + +## 🤝 협업 가이드 + +### 새로운 패키지 추가 시 +```bash +pip install +pip freeze > requirements.txt +git add requirements.txt +git commit -m "Add: " +``` + +### 마이그레이션 생성 시 +```bash +python manage.py makemigrations +python manage.py migrate + +# 커밋 +git add /migrations/ +git commit -m "Migration: " +``` + +### Pull 받은 후 +```bash +git pull +source venv/bin/activate +pip install -r requirements.txt +python manage.py migrate +``` + +--- + +## 📞 문제 발생 시 + +문제 발생 시 다음 정보를 함께 공유해주세요: +- OS 및 Python 버전: `python --version` +- PostgreSQL 버전: `psql --version` +- 전체 에러 메시지 +- 수행한 명령어 diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000..4409d1e --- /dev/null +++ b/apps/accounts/admin.py @@ -0,0 +1,153 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import ngettext +from django.contrib import messages + +from .models import User, Role, UserRoleLevel, TechStack, Report + + +class UserRoleLevelInline(admin.TabularInline): + model = UserRoleLevel + extra = 0 + fields = ["role", "level", "last_diagnosed_at"] + readonly_fields = ["last_diagnosed_at"] + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + list_display = ["id", "username", "nickname", "email", "preferred_role", "passion_level", "team_ban_count", "email_notifications_enabled", "is_staff", "created_at"] + list_filter = ["is_staff", "is_active", "created_at", "passion_level", "preferred_role"] + search_fields = ["username", "nickname", "email"] + ordering = ["-created_at"] + inlines = [UserRoleLevelInline] + actions = ["clear_passion_level", "clear_team_ban", "ban_user"] + + fieldsets = BaseUserAdmin.fieldsets + ( + ("프로필 정보", {"fields": ("nickname", "profile_image", "bio", "tech_stacks")}), + ("알림 설정", {"fields": ("email_notifications_enabled",)}), + ("관리 정보", {"fields": ("passion_level", "preferred_role", "team_ban_count")}), + ) + + def clear_passion_level(self, request, queryset): + """열정 레벨 초기화""" + count = queryset.update(passion_level=None) + self.message_user( + request, + ngettext( + f"{count}명의 열정 레벨이 초기화되었습니다.", + f"{count}명의 열정 레벨이 초기화되었습니다.", + count, + ), + ) + clear_passion_level.short_description = "🔄 선택된 사용자의 열정 레벨 초기화" + + def clear_team_ban(self, request, queryset): + """팀 밴 횟수 초기화""" + count = queryset.update(team_ban_count=0) + self.message_user( + request, + ngettext( + f"{count}명의 팀플 금지가 해제되었습니다.", + f"{count}명의 팀플 금지가 해제되었습니다.", + count, + ), + ) + clear_team_ban.short_description = "🔓 선택된 사용자의 팀플 금지 해제" + + def ban_user(self, request, queryset): + """사용자에게 팀 밴 1회 추가""" + count = 0 + for user in queryset: + user.team_ban_count += 1 + user.save() + count += 1 + self.message_user( + request, + ngettext( + f"{count}명에게 팀플 금지 1회가 추가되었습니다.", + f"{count}명에게 팀플 금지 1회가 추가되었습니다.", + count, + ), + ) + ban_user.short_description = "⛔ 선택된 사용자에게 팀 밴 1회 추가" + + +@admin.register(TechStack) +class TechStackAdmin(admin.ModelAdmin): + list_display = ["id", "name", "category", "user_count", "created_at"] + list_filter = ["category"] + search_fields = ["name"] + ordering = ["category", "name"] + fieldsets = [ + ("기본 정보", {"fields": ["name", "category"]}), + ] + + def get_queryset(self, request): + """N+1 쿼리 최적화: annotate로 user_count 미리 계산""" + from django.db.models import Count + queryset = super().get_queryset(request) + return queryset.annotate(_user_count=Count('users', distinct=True)) + + def user_count(self, obj): + """annotate된 _user_count 사용 (DB 쿼리 없음)""" + return obj._user_count + user_count.short_description = "사용자 수" + + +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + list_display = ["id", "code", "name", "created_at"] + search_fields = ["code", "name"] + ordering = ["code"] + + +@admin.register(Report) +class ReportAdmin(admin.ModelAdmin): + list_display = ["id", "reporter", "reported_user", "reason_preview", "status", "created_at"] + list_filter = ["status", "created_at"] + search_fields = ["reporter__nickname", "reported_user__nickname", "reason"] + ordering = ["-created_at"] + actions = ["approve_and_ban"] + readonly_fields = ["reporter", "reported_user", "reason", "created_at"] + + def reason_preview(self, obj): + """신고 사유 미리보기 (50자)""" + return obj.reason[:50] + "..." if len(obj.reason) > 50 else obj.reason + reason_preview.short_description = "신고 사유" + + def approve_and_ban(self, request, queryset): + """신고 승인 및 피신고자 밴""" + pending_reports = queryset.filter(status=Report.Status.PENDING) + count = 0 + + for report in pending_reports: + # 피신고자에게 팀 밴 2회 추가 + report.reported_user.team_ban_count += 2 + # TODO 팀에서 피신고자 제거하기 + + report.reported_user.save() + + # 신고 상태 업데이트 + report.status = Report.Status.APPROVED + report.admin_note = f"관리자 일괄 처리: {request.user.username}" + report.save() + count += 1 + + self.message_user( + request, + ngettext( + f"{count}명이 밴 처리되었습니다.", + f"{count}명이 밴 처리되었습니다.", + count, + ), + messages.SUCCESS, + ) + approve_and_ban.short_description = "✅ 신고 승인 및 피신고자 밴" + + +@admin.register(UserRoleLevel) +class UserRoleLevelAdmin(admin.ModelAdmin): + list_display = ["id", "user", "role", "level", "last_diagnosed_at", "updated_at"] + list_filter = ["role", "level"] + search_fields = ["user__nickname", "user__username"] + ordering = ["-updated_at"] diff --git a/apps/accounts/api_urls.py b/apps/accounts/api_urls.py new file mode 100644 index 0000000..8593cd5 --- /dev/null +++ b/apps/accounts/api_urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("check-username/", views.check_username, name="check_username"), + path("check-email/", views.check_email, name="check_email"), + path("check-nickname/", views.check_nickname, name="check_nickname"), + path("level-test/submit/", views.level_submit, name="level_submit"), + path("report/create", views.create_report, name="create_report"), +] diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..7c8c8c0 --- /dev/null +++ b/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" # 파이썬 경로 + label = "accounts" # app_label diff --git a/apps/accounts/fixtures/roles.json b/apps/accounts/fixtures/roles.json new file mode 100644 index 0000000..b49d321 --- /dev/null +++ b/apps/accounts/fixtures/roles.json @@ -0,0 +1,29 @@ +[ + { + "model": "accounts.Role", + "pk": 1, + "fields": { + "name": "PM(기획)", + "code": "PM", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.Role", + "pk": 2, + "fields": { + "name": "프론트엔드", + "code": "FRONTEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.Role", + "pk": 3, + "fields": { + "name": "백엔드", + "code": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + } +] diff --git a/apps/accounts/fixtures/tech_stacks.json b/apps/accounts/fixtures/tech_stacks.json new file mode 100644 index 0000000..1ecc28f --- /dev/null +++ b/apps/accounts/fixtures/tech_stacks.json @@ -0,0 +1,173 @@ +[ + { + "model": "accounts.TechStack", + "pk": 1, + "fields": { + "name": "Python", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 2, + "fields": { + "name": "JavaScript", + "category": "FRONTEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 3, + "fields": { + "name": "TypeScript", + "category": "FRONTEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 4, + "fields": { + "name": "Java", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 5, + "fields": { + "name": "Notion", + "category": "PM", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 6, + "fields": { + "name": "React", + "category": "FRONTEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 7, + "fields": { + "name": "Django", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 8, + "fields": { + "name": "Flask", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 9, + "fields": { + "name": "FastAPI", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 10, + "fields": { + "name": "Spring", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 11, + "fields": { + "name": "PostgreSQL", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 12, + "fields": { + "name": "MySQL", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 13, + "fields": { + "name": "MongoDB", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 14, + "fields": { + "name": "Redis", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 15, + "fields": { + "name": "Docker", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 16, + "fields": { + "name": "Git", + "category": "PM", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 17, + "fields": { + "name": "AWS", + "category": "BACKEND", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 18, + "fields": { + "name": "Figma", + "category": "PM", + "created_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "accounts.TechStack", + "pk": 19, + "fields": { + "name": "HTML/CSS", + "category": "FRONTEND", + "created_at": "2026-02-06T00:00:00Z" + } + } +] diff --git a/apps/accounts/forms.py b/apps/accounts/forms.py new file mode 100644 index 0000000..ef33e54 --- /dev/null +++ b/apps/accounts/forms.py @@ -0,0 +1,166 @@ +from django import forms +import re +from .models import User, TechStack + + +# ============ 공통 Validator ============ +class NicknameValidator: + """닉네임 검증 로직 통합""" + MIN_LENGTH = 2 + MAX_LENGTH = 20 + PATTERN = r'^[a-zA-Z0-9가-힣_-]+$' + + @staticmethod + def validate(nickname, exclude_user_pk=None): + """ + 닉네임 검증 (길이 + 패턴 + 중복) + + Args: + nickname: 검증할 닉네임 + exclude_user_pk: 제외할 사용자 PK (프로필 수정 시) + + Returns: + (valid, message) 튜플 + """ + nickname = (nickname or "").strip() + + # 필수값 확인 + if not nickname: + return False, "닉네임은 필수입니다." + + # 길이 검증 + if len(nickname) < NicknameValidator.MIN_LENGTH: + return False, f"닉네임은 최소 {NicknameValidator.MIN_LENGTH}자 이상이어야 합니다." + if len(nickname) > NicknameValidator.MAX_LENGTH: + return False, f"닉네임은 최대 {NicknameValidator.MAX_LENGTH}자 이하여야 합니다." + + # 특수문자 검증 + if not re.match(NicknameValidator.PATTERN, nickname): + return False, "닉네임은 한글, 영문, 숫자, 밑줄(_), 하이픈(-)만 사용 가능합니다." + + # 중복 검증 + query = User.objects.filter(nickname=nickname) + if exclude_user_pk: + query = query.exclude(pk=exclude_user_pk) + + if query.exists(): + return False, "이미 사용 중인 닉네임입니다." + + return True, "사용 가능한 닉네임입니다." + + +# ============ Forms ============ + + +class OnboardingForm(forms.ModelForm): + tech_stacks = forms.ModelMultipleChoiceField( + queryset=TechStack.objects.all().order_by("category", "name"), + widget=forms.CheckboxSelectMultiple, + required=False, + label="기술 스택 (선택)", + help_text="보유한 기술을 선택하세요", + ) + + class Meta: + model = User + fields = ["nickname", "github_id", "profile_image", "tech_stacks"] + widgets = { + "nickname": forms.TextInput(attrs={ + "class": "form-control", + "placeholder": "닉네임을 입력하세요", + }), + "github_id": forms.TextInput(attrs={ + "class": "form-control", + "placeholder": "GitHub 아이디 (선택)", + }), + "profile_image": forms.FileInput(attrs={ + "class": "form-control", + "accept": "image/*", + }), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields["tech_stacks"].initial = self.instance.tech_stacks.all() + + def clean_nickname(self): + nick = self.cleaned_data.get("nickname") or "" + valid, message = NicknameValidator.validate(nick, self.instance.pk) + if not valid: + raise forms.ValidationError(message) + return nick.strip() + + def clean_github_id(self): + github_id = (self.cleaned_data.get("github_id") or "").strip() + if github_id and User.objects.filter(github_id=github_id).exclude(pk=self.instance.pk).exists(): + raise forms.ValidationError("이미 등록된 GitHub 아이디입니다.") + return github_id or None + + def save(self, commit=True): + user = super().save(commit=False) + # Always save user record first (with profile_image) + user.save() + if "tech_stacks" in self.cleaned_data: + user.tech_stacks.set(self.cleaned_data.get("tech_stacks", [])) + return user + + +class ProfileUpdateForm(forms.ModelForm): + tech_stacks = forms.ModelMultipleChoiceField( + queryset=TechStack.objects.all().order_by("category", "name"), + widget=forms.CheckboxSelectMultiple, + required=False, + label="기술 스택 (선택)", + help_text="보유한 기술을 선택하세요", + ) + + class Meta: + model = User + fields = ["nickname", "github_id", "profile_image", "bio", "tech_stacks"] + widgets = { + "nickname": forms.TextInput(attrs={ + "class": "form-control", + "placeholder": "닉네임을 입력하세요", + }), + "github_id": forms.TextInput(attrs={ + "class": "form-control", + "placeholder": "GitHub 아이디 (선택)", + }), + "profile_image": forms.FileInput(attrs={ + "class": "form-control", + "accept": "image/*", + }), + "bio": forms.Textarea(attrs={ + "class": "form-control", + "rows": 3, + "placeholder": "자기소개를 입력하세요", + }), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields["tech_stacks"].initial = self.instance.tech_stacks.all() + + def clean_nickname(self): + nick = self.cleaned_data.get("nickname") or "" + valid, message = NicknameValidator.validate(nick, self.instance.pk) + if not valid: + raise forms.ValidationError(message) + return nick.strip() + + def clean_github_id(self): + github_id = (self.cleaned_data.get("github_id") or "").strip() + if github_id and User.objects.filter(github_id=github_id).exclude(pk=self.instance.pk).exists(): + raise forms.ValidationError("이미 등록된 GitHub 아이디입니다.") + return github_id or None + + def save(self, commit=True): + user = super().save(commit=False) + + if commit: + user.save() + if "tech_stacks" in self.cleaned_data: + user.tech_stacks.set(self.cleaned_data.get("tech_stacks", [])) + return user diff --git a/apps/accounts/management/__init__.py b/apps/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/management/commands/__init__.py b/apps/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/management/commands/load_techstacks.py b/apps/accounts/management/commands/load_techstacks.py new file mode 100644 index 0000000..0bc2d02 --- /dev/null +++ b/apps/accounts/management/commands/load_techstacks.py @@ -0,0 +1,29 @@ +from django.core.management.base import BaseCommand +from django.core.management import call_command + + +class Command(BaseCommand): + help = 'JSON 파일에서 기술 스택 데이터를 로드합니다' + + def add_arguments(self, parser): + parser.add_argument( + '--reset', + action='store_true', + help='기존 기술 스택 데이터를 먼저 삭제합니다', + ) + + def handle(self, *args, **options): + # 기존 데이터 삭제 옵션 + if options['reset']: + from apps.accounts.models import TechStack + TechStack.objects.all().delete() + self.stdout.write(self.style.WARNING('기존 기술 스택 데이터를 삭제했습니다')) + + # fixture 로드 + try: + call_command('loaddata', 'tech_stacks') + self.stdout.write( + self.style.SUCCESS('✅ 기술 스택 데이터를 성공적으로 로드했습니다!') + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f'❌ 오류: {e}')) diff --git a/apps/accounts/management/commands/seed_roles.py b/apps/accounts/management/commands/seed_roles.py new file mode 100644 index 0000000..992f5d1 --- /dev/null +++ b/apps/accounts/management/commands/seed_roles.py @@ -0,0 +1,33 @@ +from django.core.management.base import BaseCommand +from apps.accounts.models import Role + + +class Command(BaseCommand): + help = "Role 시드 데이터 생성 (PM, FRONTEND, BACKEND)" + + def handle(self, *args, **options): + roles = [ + {"code": "PM", "name": "PM(기획)"}, + {"code": "FRONTEND", "name": "프론트엔드"}, + {"code": "BACKEND", "name": "백엔드"}, + ] + + created_count = 0 + for role_data in roles: + role, created = Role.objects.get_or_create( + code=role_data["code"], + defaults={"name": role_data["name"]}, + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f" ✓ Role '{role.code}' 생성됨") + ) + else: + self.stdout.write( + self.style.WARNING(f" - Role '{role.code}' 이미 존재함") + ) + + self.stdout.write( + self.style.SUCCESS(f"\n총 {created_count}개의 Role이 생성되었습니다.") + ) diff --git a/apps/accounts/middleware.py b/apps/accounts/middleware.py new file mode 100644 index 0000000..a172c1f --- /dev/null +++ b/apps/accounts/middleware.py @@ -0,0 +1,20 @@ +from django.shortcuts import redirect + +EXEMPT_PREFIXES = ( + "/admin/", + "/accounts/", + "/logout/", + "/static/", + "/media/", + "/api/", # Swagger 및 API 테스트용 +) + +class RequireProfileMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated and not request.user.nickname: + if not request.path.startswith(EXEMPT_PREFIXES): + return redirect("/accounts/onboarding/profile/") + return self.get_response(request) diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..343c780 --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 5.2.10 on 2026-01-30 05:10 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +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='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(choices=[('PM', 'PM(기획)'), ('FRONTEND', '프론트엔드'), ('BACKEND', '백엔드')], help_text='역할 코드 (PM/FRONTEND/BACKEND)', max_length=20, unique=True)), + ('name', models.CharField(help_text='역할 표시명', max_length=30)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'roles', + }, + ), + 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')), + ('nickname', models.CharField(blank=True, help_text='서비스 내 표시 닉네임', max_length=50, null=True, unique=True)), + ('profile_image_url', models.TextField(blank=True, help_text='프로필 이미지 URL', null=True)), + ('bio', models.TextField(blank=True, help_text='자기소개', null=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()), + ], + ), + migrations.CreateModel( + name='UserRoleLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.SmallIntegerField(help_text='실력 레벨 (1~4)', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4)])), + ('last_diagnosed_at', models.DateTimeField(blank=True, help_text='마지막 진단 일시', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_levels', to='accounts.role')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_levels', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'user_role_levels', + 'indexes': [models.Index(fields=['role', 'level'], name='user_role_l_role_id_b06a10_idx')], + 'constraints': [models.UniqueConstraint(fields=('user', 'role'), name='uq_user_role_level'), models.CheckConstraint(condition=models.Q(('level__gte', 1), ('level__lte', 4)), name='ck_user_role_level_range')], + }, + ), + ] diff --git a/apps/accounts/migrations/0002_remove_user_profile_image_url_user_profile_image.py b/apps/accounts/migrations/0002_remove_user_profile_image_url_user_profile_image.py new file mode 100644 index 0000000..12bfcc5 --- /dev/null +++ b/apps/accounts/migrations/0002_remove_user_profile_image_url_user_profile_image.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.10 on 2026-01-30 07:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='profile_image_url', + ), + migrations.AddField( + model_name='user', + name='profile_image', + field=models.ImageField(blank=True, help_text='프로필 이미지', null=True, upload_to='profiles/'), + ), + ] diff --git a/apps/accounts/migrations/0003_user_team_ban_count_report.py b/apps/accounts/migrations/0003_user_team_ban_count_report.py new file mode 100644 index 0000000..8f59ed1 --- /dev/null +++ b/apps/accounts/migrations/0003_user_team_ban_count_report.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.10 on 2026-01-31 04:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_remove_user_profile_image_url_user_profile_image'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='team_ban_count', + field=models.PositiveSmallIntegerField(default=0, help_text='남은 팀플 참여 금지 횟수'), + ), + migrations.CreateModel( + name='Report', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.TextField(help_text='신고 사유')), + ('status', models.CharField(choices=[('PENDING', '대기중'), ('APPROVED', '승인'), ('REJECTED', '거절')], default='PENDING', help_text='신고 처리 상태', max_length=10)), + ('admin_note', models.TextField(blank=True, help_text='운영자 메모', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('processed_at', models.DateTimeField(blank=True, help_text='처리 일시', null=True)), + ('reported_user', models.ForeignKey(help_text='피신고자', on_delete=django.db.models.deletion.CASCADE, related_name='reports_received', to=settings.AUTH_USER_MODEL)), + ('reporter', models.ForeignKey(help_text='신고자', on_delete=django.db.models.deletion.CASCADE, related_name='reports_made', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'reports', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['status', 'created_at'], name='reports_status_19e159_idx')], + }, + ), + ] diff --git a/apps/accounts/migrations/0004_user_github_id.py b/apps/accounts/migrations/0004_user_github_id.py new file mode 100644 index 0000000..37e238c --- /dev/null +++ b/apps/accounts/migrations/0004_user_github_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-01 07:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_user_team_ban_count_report'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='github_id', + field=models.CharField(blank=True, help_text='GitHub 아이디', max_length=39, null=True, unique=True), + ), + ] diff --git a/apps/accounts/migrations/0005_user_passion_level.py b/apps/accounts/migrations/0005_user_passion_level.py new file mode 100644 index 0000000..197f699 --- /dev/null +++ b/apps/accounts/migrations/0005_user_passion_level.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.10 on 2026-02-01 14:08 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_user_github_id'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='passion_level', + field=models.SmallIntegerField(blank=True, help_text='열정 레벨 (1~4)', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4)]), + ), + ] diff --git a/apps/accounts/migrations/0006_techstack_user_tech_stacks.py b/apps/accounts/migrations/0006_techstack_user_tech_stacks.py new file mode 100644 index 0000000..01a530b --- /dev/null +++ b/apps/accounts/migrations/0006_techstack_user_tech_stacks.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.10 on 2026-02-02 14:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_user_passion_level'), + ] + + operations = [ + migrations.CreateModel( + name='TechStack', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='기술 이름 (Python, React 등)', max_length=50, unique=True)), + ('category', models.CharField(choices=[('LANGUAGE', '프로그래밍 언어'), ('FRONTEND', '프론트엔드'), ('BACKEND', '백엔드'), ('DATABASE', '데이터베이스'), ('TOOL', '개발 도구')], help_text='기술 카테고리', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'tech_stacks', + 'ordering': ['category', 'name'], + 'indexes': [models.Index(fields=['category'], name='tech_stacks_categor_f30f5c_idx')], + }, + ), + migrations.AddField( + model_name='user', + name='tech_stacks', + field=models.ManyToManyField(blank=True, help_text='사용자가 보유한 기술 스택', related_name='users', to='accounts.techstack'), + ), + ] diff --git a/apps/accounts/migrations/0007_user_email_notifications_enabled.py b/apps/accounts/migrations/0007_user_email_notifications_enabled.py new file mode 100644 index 0000000..7139c6f --- /dev/null +++ b/apps/accounts/migrations/0007_user_email_notifications_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-08 07:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0006_techstack_user_tech_stacks'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='email_notifications_enabled', + field=models.BooleanField(default=True, help_text='이메일 알림 수신 여부'), + ), + ] diff --git a/apps/accounts/migrations/0008_alter_user_email_notifications_enabled.py b/apps/accounts/migrations/0008_alter_user_email_notifications_enabled.py new file mode 100644 index 0000000..f3a283b --- /dev/null +++ b/apps/accounts/migrations/0008_alter_user_email_notifications_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-08 08:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0007_user_email_notifications_enabled'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email_notifications_enabled', + field=models.BooleanField(default=False, help_text='이메일 알림 수신 여부'), + ), + ] diff --git a/apps/accounts/migrations/0009_alter_techstack_category.py b/apps/accounts/migrations/0009_alter_techstack_category.py new file mode 100644 index 0000000..606f4bc --- /dev/null +++ b/apps/accounts/migrations/0009_alter_techstack_category.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-09 07:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0008_alter_user_email_notifications_enabled'), + ] + + operations = [ + migrations.AlterField( + model_name='techstack', + name='category', + field=models.CharField(choices=[('FRONTEND', '프론트엔드'), ('BACKEND', '백엔드'), ('PM', '기획')], help_text='기술 카테고리', max_length=20), + ), + ] diff --git a/apps/accounts/migrations/0010_user_preferred_role.py b/apps/accounts/migrations/0010_user_preferred_role.py new file mode 100644 index 0000000..b3391ee --- /dev/null +++ b/apps/accounts/migrations/0010_user_preferred_role.py @@ -0,0 +1,19 @@ +# Generated migration for preferred_role field + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0009_alter_techstack_category'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='preferred_role', + field=models.ForeignKey(blank=True, help_text='팀매칭 신청 시 선택한 직군', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applicants', to='accounts.role'), + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..ea8a8e7 --- /dev/null +++ b/apps/accounts/models.py @@ -0,0 +1,299 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator + + +class TechStack(models.Model): + """ + 기술 스택 (마스터 데이터) + - 프로그래밍 언어, 프레임워크, 도구 등 + - 관리자만 추가/수정 가능 + """ + + class Category(models.TextChoices): + FRONTEND = "FRONTEND", "프론트엔드" + BACKEND = "BACKEND", "백엔드" + PM = "PM", "기획" + + name = models.CharField( + max_length=50, + unique=True, + help_text="기술 이름 (Python, React 등)", + ) + + category = models.CharField( + max_length=20, + choices=Category.choices, + help_text="기술 카테고리", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "tech_stacks" + ordering = ["category", "name"] + indexes = [ + models.Index(fields=["category"]), + ] + + def __str__(self) -> str: + return self.name + + +class User(AbstractUser): + """ + Custom User for StartLine.dev + - allauth 사용 (password_hash는 Django가 내부적으로 관리) + - 로그인 후 프로필 설정 화면에서 nickname 입력 + """ + + nickname = models.CharField( + max_length=50, + unique=True, + null=True, + blank=True, + help_text="서비스 내 표시 닉네임", + ) + + profile_image = models.ImageField( + upload_to="profiles/", + null=True, + blank=True, + help_text="프로필 이미지", + ) + + bio = models.TextField( + null=True, + blank=True, + help_text="자기소개", + ) + + github_id = models.CharField( + max_length=39, + unique=True, + null=True, + blank=True, + help_text="GitHub 아이디", + ) + + passion_level = models.SmallIntegerField( + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(4)], + help_text="열정 레벨 (1~4)", + ) + + tech_stacks = models.ManyToManyField( + TechStack, + related_name="users", + blank=True, + help_text="사용자가 보유한 기술 스택", + ) + + team_ban_count = models.PositiveSmallIntegerField( + default=0, + help_text="남은 팀플 참여 금지 횟수", + ) + + email_notifications_enabled = models.BooleanField( + default=False, + help_text="이메일 알림 수신 여부", + ) + + preferred_role = models.ForeignKey( + "Role", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="applicants", + help_text="팀매칭 신청 시 선택한 직군", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def is_banned(self) -> bool: + """팀플 참여 금지 상태 여부""" + return self.team_ban_count > 0 + + def is_profile_completed(self) -> bool: + """프로필 설정 완료 여부""" + return bool(self.nickname) + + def get_role_level(self, role_code: str) -> int: + """특정 역할의 레벨 조회 (1~4, 없으면 0)""" + try: + role = Role.objects.get(code=role_code) + user_level = self.role_levels.filter(role=role).first() + return user_level.level if user_level else 0 + except Role.DoesNotExist: + return 0 + + def __str__(self) -> str: + return self.nickname or self.username + + +class Role(models.Model): + """ + 역할 (시드 데이터, 고정) + - PM: 기획 + - FRONTEND: 프론트엔드 + - BACKEND: 백엔드 + """ + + class RoleCode(models.TextChoices): + PM = "PM", "PM(기획)" + FRONTEND = "FRONTEND", "프론트엔드" + BACKEND = "BACKEND", "백엔드" + + code = models.CharField( + max_length=20, + unique=True, + choices=RoleCode.choices, + help_text="역할 코드 (PM/FRONTEND/BACKEND)", + ) + + name = models.CharField( + max_length=30, + help_text="역할 표시명", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "roles" + + def __str__(self) -> str: + return self.name + + +class UserRoleLevel(models.Model): + """ + 사용자별 역할 레벨 (1~4) + - 역할별로 다른 레벨 관리 + - 설문 기반 진단, 재진단 가능 + """ + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="role_levels", + ) + + role = models.ForeignKey( + Role, + on_delete=models.CASCADE, + related_name="user_levels", + ) + + level = models.SmallIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(4)], + help_text="실력 레벨 (1~4)", + ) + + last_diagnosed_at = models.DateTimeField( + null=True, + blank=True, + help_text="마지막 진단 일시", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "user_role_levels" + constraints = [ + models.UniqueConstraint( + fields=["user", "role"], + name="uq_user_role_level", + ), + models.CheckConstraint( + check=models.Q(level__gte=1, level__lte=4), + name="ck_user_role_level_range", + ), + ] + indexes = [ + models.Index(fields=["role", "level"]), + ] + + def __str__(self) -> str: + return f"{self.user}:{self.role.code}=Lv.{self.level}" + + +class Report(models.Model): + """ + 사용자 신고 + - 팀원을 신고하면 사유를 작성 + - 운영자가 승인 시 피신고자에게 팀플 2회 금지 제재 + """ + + class Status(models.TextChoices): + PENDING = "PENDING", "대기중" + APPROVED = "APPROVED", "승인" + REJECTED = "REJECTED", "거절" + + reporter = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="reports_made", + help_text="신고자", + ) + + reported_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="reports_received", + help_text="피신고자", + ) + + reason = models.TextField( + help_text="신고 사유", + ) + + status = models.CharField( + max_length=10, + choices=Status.choices, + default=Status.PENDING, + help_text="신고 처리 상태", + ) + + admin_note = models.TextField( + null=True, + blank=True, + help_text="운영자 메모", + ) + + created_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField( + null=True, + blank=True, + help_text="처리 일시", + ) + + class Meta: + db_table = "reports" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["status", "created_at"]), + ] + + def approve(self): + """신고 승인 - 피신고자에게 2회 팀플 금지 제재""" + from django.utils import timezone + self.status = self.Status.APPROVED + self.processed_at = timezone.now() + self.reported_user.team_ban_count += 2 + self.reported_user.save(update_fields=["team_ban_count"]) + self.save() + + def reject(self, admin_note: str = None): + """신고 거절""" + from django.utils import timezone + self.status = self.Status.REJECTED + self.processed_at = timezone.now() + if admin_note: + self.admin_note = admin_note + self.save() + + def __str__(self) -> str: + return f"{self.reporter} → {self.reported_user} ({self.get_status_display()})" diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py new file mode 100644 index 0000000..b5203f4 --- /dev/null +++ b/apps/accounts/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +class ReportCreateRequestSerializer(serializers.Serializer): + reported_user_id = serializers.IntegerField() + reason = serializers.CharField(min_length=1, max_length=2000) + +class ReportCreateResponseSerializer(serializers.Serializer): + ok = serializers.BooleanField() + report_id = serializers.IntegerField() diff --git a/apps/accounts/tests/__init__.py b/apps/accounts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/tests/test_level.py b/apps/accounts/tests/test_level.py new file mode 100644 index 0000000..7110e2b --- /dev/null +++ b/apps/accounts/tests/test_level.py @@ -0,0 +1,55 @@ +from django.test import TestCase +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.utils import timezone + +from ..models import Role, UserRoleLevel + +User = get_user_model() + +class LevelFlowTests(TestCase): + def setUp(self): + self.password = "testpass1234" + self.user = User.objects.create_user(username="testuser", password=self.password) + + # ✅ 온보딩/프로필 완료 가드가 있으면 이게 필요 + self.user.nickname = "tester" + self.user.save(update_fields=["nickname"]) + + ok = self.client.login(username="testuser", password=self.password) + self.assertTrue(ok) + + self.backend = Role.objects.create(code="BACKEND", name="백엔드") + Role.objects.create(code="FRONTEND", name="프론트엔드") + Role.objects.create(code="PM", name="PM(기획)") + + def test_get_level_test_ok(self): + url = reverse("accounts:level_test") + "?role=BACKEND" + res = self.client.get(url) + # 디버깅 필요하면 아래 1줄 잠깐 켜봐 + # print(res.status_code, res.headers.get("Location")) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context.get("role_code"), "BACKEND") + + def test_submit_creates_user_role_level(self): + url = reverse("accounts:level_submit") + + # ✅ view는 role로 받는다 + res = self.client.post(url, data={"role": "BACKEND", "level": "3"}) + self.assertEqual(res.status_code, 302) + + obj = UserRoleLevel.objects.get(user=self.user, role=self.backend) + self.assertEqual(obj.level, 3) + + def test_result_shows_level(self): + UserRoleLevel.objects.create( + user=self.user, + role=self.backend, + level=2, + last_diagnosed_at=timezone.now(), + ) + + url = reverse("accounts:test_result") + "?role=BACKEND" + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context.get("level"), 2) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..a9cc70b --- /dev/null +++ b/apps/accounts/urls.py @@ -0,0 +1,28 @@ +from django.urls import path +from . import views + +app_name = "accounts" + +urlpatterns = [ + # 검증 API + path("check-username/", views.check_username, name="check_username"), + path("check-email/", views.check_email, name="check_email"), + path("check-nickname/", views.check_nickname, name="check_nickname"), + + # 온보딩 + path("onboarding/profile/", views.onboarding_profile, name="onboarding_profile"), + + # 레벨 진단 (Test) + path("level-test/", views.level_test, name="level_test"), # level_test.html + path("level-test/submit/", views.level_submit, name="level_submit"), + path("level-test/result/", views.test_result, name="test_result"), # test_result.html + + # 마이페이지 + path("mypage/", views.mypage, name="mypage"), # mypage.html + + # 프로필 수정 + path("profile/edit/", views.profile_edit, name="profile_edit"), # profile_edit.html + + # 회원 탈퇴 + path("withdraw/", views.withdraw, name="withdraw"), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py new file mode 100644 index 0000000..115514a --- /dev/null +++ b/apps/accounts/views.py @@ -0,0 +1,292 @@ +import json +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, render, redirect +from django.contrib.auth import logout +from django.contrib import messages +from django.http import HttpResponseBadRequest, JsonResponse +from django.views.decorators.http import require_GET, require_POST + +from drf_spectacular.utils import extend_schema +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status + +from .forms import OnboardingForm, ProfileUpdateForm +from .models import Role, User, UserRoleLevel, Report +from .serializers import ReportCreateRequestSerializer, ReportCreateResponseSerializer + + +@require_GET +def check_username(request): + """아이디 중복 확인 API""" + username = request.GET.get("username", "").strip() + + if not username: + return JsonResponse({"available": False, "message": "아이디를 입력해주세요."}) + + if len(username) < 4: + return JsonResponse({"available": False, "message": "아이디는 4자 이상이어야 합니다."}) + + if User.objects.filter(username=username).exists(): + return JsonResponse({"available": False, "message": "이미 사용 중인 아이디입니다."}) + + return JsonResponse({"available": True, "message": "사용 가능한 아이디입니다."}) + + +@require_GET +def check_email(request): + """이메일 중복 확인 API""" + email = request.GET.get("email", "").strip() + + if not email: + return JsonResponse({"available": False, "message": "이메일을 입력해주세요."}) + + if User.objects.filter(email=email).exists(): + return JsonResponse({"available": False, "message": "이미 사용 중인 이메일입니다."}) + + return JsonResponse({"available": True, "message": "사용 가능한 이메일입니다."}) + + +@require_GET +def check_nickname(request): + """닉네임 중복 확인 API""" + from .forms import NicknameValidator + + nickname = request.GET.get("nickname", "").strip() + user_id = request.GET.get("user_id") # 프로필 수정 시 자신의 닉네임 제외 + + valid, message = NicknameValidator.validate(nickname, exclude_user_pk=user_id) + + return JsonResponse({ + "available": valid, + "message": message, + }) + + +@login_required +def level_test(request): + """ + 레벨 진단 테스트 페이지 렌더링 + + - 특정 역할(role_code)에 대한 테스트를 진행 + - role_code는 GET 파라미터로 전달받음 -> 프론트에서 설정 필요 + - 'account/level_test.html' 템플릿을 렌더링 + """ + role_code = request.GET.get("role") + context = {"role_code": role_code} + return render(request, "account/level_test.html", context) + +@login_required +@require_POST +def level_submit(request): + """ + 레벨 테스트 결과 제출 처리 (API) + + - POST 요청으로 track(역할 코드), level, total_score, answers를 JSON으로 받음 + - UserRoleLevel 모델에 결과 저장 또는 업데이트 + - JSON 응답으로 success 여부 반환 + """ + try: + data = json.loads(request.body) + role_code = data.get("track") + level = data.get("level") + + if not role_code or level is None: + return JsonResponse({"success": False, "error": "필수 데이터가 없습니다."}) + + try: + role = Role.objects.get(code=role_code) + except Role.DoesNotExist: + return JsonResponse({"success": False, "error": f"역할을 찾을 수 없습니다: {role_code}"}) + + UserRoleLevel.objects.update_or_create( + user=request.user, + role=role, + defaults={ + "level": int(level), + "last_diagnosed_at": timezone.now(), + }, + ) + + return JsonResponse({"success": True}) + except json.JSONDecodeError: + return JsonResponse({"success": False, "error": "잘못된 JSON 형식입니다."}) + except Exception as e: + import traceback + traceback.print_exc() + return JsonResponse({"success": False, "error": "오류가 발생했습니다."}) + return JsonResponse({"success": False, "error": str(e)}) + +@login_required +def test_result(request): + """ + 레벨 테스트 결과 + + - 특정 역할(role_code)에 대한 사용자의 레벨 정보를 조회 + - 'test/test_result.html' 템플릿을 렌더링 + - 템플릿에 사용자 정보, 역할명, 레벨 전달 + """ + role_code = request.GET.get("role") + + role = get_object_or_404(Role, code=role_code) + url_level = ( + UserRoleLevel.objects.filter(user=request.user, role=role) + .select_related("role") + .first() + ) + + context = { + "user_obj": request.user, + "role": role, # role.name 출력 가능 + "level": url_level.level if url_level else None, + } + return render(request, "account/test_result.html", context) + + +@login_required +def profile_edit(request): + """프로필 수정""" + if request.method == "POST": + form = ProfileUpdateForm( + request.POST, + request.FILES, + instance=request.user, + ) + if form.is_valid(): + form.save() + messages.success(request, "프로필이 수정되었습니다.") + return redirect("accounts:mypage") + else: + form = ProfileUpdateForm(instance=request.user) + + context = {"form": form} + return render(request, "account/profile_edit.html", context) + + +@login_required +def onboarding_profile(request): + """온보딩: 최초 프로필 설정""" + if request.method == "POST": + form = OnboardingForm( + request.POST, + request.FILES, + instance=request.user, + ) + if form.is_valid(): + form.save() + return redirect("/") + else: + form = OnboardingForm(instance=request.user) + + context = {"form": form} + return render(request, "account/onboarding_profile.html", context) + + +@login_required +def mypage(request): + """ + 마이페이지 조회 뷰 + + - 로그인한 사용자의 정보, 역할 레벨, 팀 프로젝트 참여 내역 등을 조회 + - 'account/mypage.html' 템플릿을 렌더링 + - 프로젝트 내역은 team_memberships -> team -> project 경로로 조회한다. + """ + + user = request.user + + # 역할별 스킬 레벨 (user_role_levels + roles) + role_levels = user.role_levels.select_related("role").all() + + # 팀 프로젝트 참여 내역 (team_memberships + role + team + project) + memberships = ( + user.team_memberships + .select_related("team__project", "role") + .order_by("-joined_at") + ) + + context = { + "user_obj": user, + "role_levels": role_levels, + "memberships": memberships, + } + return render(request, "account/mypage.html", context) + + +@login_required +def withdraw(request): + """회원 탈퇴""" + if request.method == "POST": + user = request.user + logout(request) + user.delete() + messages.success(request, "회원 탈퇴가 완료되었습니다.") + return redirect("/") + + return render(request, "account/withdraw.html") + +@extend_schema( + tags=["Accounts"], + summary="유저 신고 생성", + request=ReportCreateRequestSerializer, + responses={201: ReportCreateResponseSerializer, 400: None, 409: None}, +) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def create_report(request): + ser = ReportCreateRequestSerializer(data=request.data) + ser.is_valid(raise_exception=True) + + reported_user_id = ser.validated_data["reported_user_id"] + reason = ser.validated_data["reason"] + + if not reported_user_id: + return JsonResponse({"ok": False, "error": "reported_user_id is required"}, status=400) + if not reason: + return JsonResponse({"ok": False, "error": "reason is required"}, status=400) + + reported_user = get_object_or_404(User, pk=reported_user_id) + + # 자기 자신 신고 방지 + if reported_user.id == request.user.id: + return JsonResponse({"ok": False, "error": "cannot report yourself"}, status=400) + + # (선택) 동일 대상 중복 신고 방지: 대기중(PENDING) 하나만 허용 같은 정책 + if Report.objects.filter( + reporter=request.user, + reported_user=reported_user, + status=Report.Status.PENDING, + ).exists(): + return JsonResponse({"ok": False, "error": "already reported (pending)"}, status=409) + + report = Report.objects.create( + reporter=request.user, + reported_user=reported_user, + reason=reason, + ) + + return JsonResponse({"ok": True, "report_id": report.id}, status=201) + + + +@login_required +def mypage(request): + user = request.user + + qs = user.role_levels.select_related("role").all() + role_levels = {x.role.code.upper(): x.level for x in qs} # {"FRONTEND": 3, ...} + + memberships = ( + user.team_memberships + .select_related("team__project", "role") + .order_by("-joined_at") + ) + + return render(request, "account/mypage.html", { + "user_obj": user, + "role_levels": role_levels, + "memberships": memberships, + }) + diff --git a/apps/guides/__init__.py b/apps/guides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/guides/admin.py b/apps/guides/admin.py new file mode 100644 index 0000000..2629201 --- /dev/null +++ b/apps/guides/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin + +from .models import GuideCard, GuideTask, GuideTaskProgress, ProjectProgress + + +class GuideTaskInline(admin.TabularInline): + model = GuideTask + extra = 0 + fields = ["title", "description", "order_no", "is_required"] + + +@admin.register(GuideCard) +class GuideCardAdmin(admin.ModelAdmin): + list_display = ["id", "role", "order_no", "title", "is_active", "created_at"] + list_filter = ["role", "is_active"] + search_fields = ["title"] + ordering = ["role", "order_no"] + inlines = [GuideTaskInline] + + +@admin.register(GuideTask) +class GuideTaskAdmin(admin.ModelAdmin): + list_display = ["id", "card", "title", "order_no", "is_required"] + list_filter = ["is_required", "card__role"] + search_fields = ["title"] + ordering = ["card__role", "card__order_no", "order_no"] + + +@admin.register(GuideTaskProgress) +class GuideTaskProgressAdmin(admin.ModelAdmin): + list_display = ["id", "task", "project", "is_completed", "completed_at"] + list_filter = ["is_completed", "project"] + search_fields = ["task__title", "project__title"] + ordering = ["-updated_at"] + + +@admin.register(ProjectProgress) +class ProjectProgressAdmin(admin.ModelAdmin): + list_display = ["id", "project", "role", "completed_tasks", "total_tasks", "progress_percent"] + list_filter = ["role", "project"] + search_fields = ["project__title"] + ordering = ["-updated_at"] diff --git a/apps/guides/api_urls.py b/apps/guides/api_urls.py new file mode 100644 index 0000000..d6204e2 --- /dev/null +++ b/apps/guides/api_urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import api_views + +app_name = "guides_api" + +urlpatterns = [ + path("card//toggle/", api_views.toggle_card, name="toggle_card"), +] \ No newline at end of file diff --git a/apps/guides/api_views.py b/apps/guides/api_views.py new file mode 100644 index 0000000..d8c1ef5 --- /dev/null +++ b/apps/guides/api_views.py @@ -0,0 +1,60 @@ +from django.shortcuts import get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.http import require_POST +import json + +from apps.projects.models import Project +from .models import GuideCard, GuideTask, GuideTaskProgress, ProjectProgress + + +@login_required +@require_POST +def toggle_card(request, card_id): + """카드 완료/미완료 토글""" + try: + data = json.loads(request.body) + project_id = data.get('project_id') + is_completed = data.get('is_completed') + + project = get_object_or_404(Project, id=project_id) + card = get_object_or_404(GuideCard, id=card_id) + + # 카드의 모든 태스크 완료/미완료 처리 + tasks = card.tasks.all() + for task in tasks: + GuideTaskProgress.objects.update_or_create( + task=task, + project=project, + defaults={'is_completed': is_completed} + ) + + # ProjectProgress 업데이트 + update_project_progress(project, card.role) + + return JsonResponse({'success': True}) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}, status=400) + + +def update_project_progress(project, role): + """역할별 진척도 업데이트""" + role_cards = GuideCard.objects.filter(role=role, is_active=True) + all_tasks = GuideTask.objects.filter(card__in=role_cards) + + total = all_tasks.count() + completed = GuideTaskProgress.objects.filter( + task__in=all_tasks, + project=project, + is_completed=True + ).count() + + ProjectProgress.objects.update_or_create( + project=project, + role=role, + defaults={ + 'total_tasks': total, + 'completed_tasks': completed + } + ) \ No newline at end of file diff --git a/apps/guides/apps.py b/apps/guides/apps.py new file mode 100644 index 0000000..6ed821d --- /dev/null +++ b/apps/guides/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GuidesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.guides" + verbose_name = "가이드" diff --git a/apps/guides/fixtures/guides.json b/apps/guides/fixtures/guides.json new file mode 100644 index 0000000..c722403 --- /dev/null +++ b/apps/guides/fixtures/guides.json @@ -0,0 +1,1458 @@ +[ + { + "model": "guides.GuideCard", + "pk": 1, + "fields": { + "role": 1, + "title": "1. 팀 커뮤니케이션 채널 개설", + "content_md": "디스코드/슬랙 등 팀플용 채널 생성", + "order_no": 1, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 1, + "fields": { + "card": 1, + "title": "디스코드/슬랙 등 팀플용 채널 생성", + "description": "검색 키워드: 디스코드 서버 만들기 / 슬랙 워크스페이스 만들기", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 2, + "fields": { + "card": 1, + "title": "팀원 이메일로 초대 링크 공유", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 2, + "fields": { + "role": 1, + "title": "2. 문서 협업 툴 준비", + "content_md": "노션/구글독스 계정 생성", + "order_no": 2, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 3, + "fields": { + "card": 2, + "title": "노션/구글독스 계정 생성", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 4, + "fields": { + "card": 2, + "title": "팀 회의록 페이지 생성 및 공유", + "description": "검색 키워드: 노션 회의록 템플릿", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 3, + "fields": { + "role": 1, + "title": "3. 첫 팀 미팅 진행", + "content_md": "", + "order_no": 3, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 5, + "fields": { + "card": 3, + "title": "자기소개", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 6, + "fields": { + "card": 3, + "title": "프로젝트 참여 목적 공유", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 7, + "fields": { + "card": 3, + "title": "팀 규칙 간단히 정하기", + "description": "", + "order_no": 3, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 4, + "fields": { + "role": 1, + "title": "4. 기본 운영 규칙 합의", + "content_md": "", + "order_no": 4, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 8, + "fields": { + "card": 4, + "title": "정기 회의 주기", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 9, + "fields": { + "card": 4, + "title": "스크럼(데일리/주간) 여부", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 10, + "fields": { + "card": 4, + "title": "응답 시간 기준", + "description": "", + "order_no": 3, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 11, + "fields": { + "card": 4, + "title": "연락 불가 일정 미리 공유", + "description": "", + "order_no": 4, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 5, + "fields": { + "role": 1, + "title": "5. 아이디어 공유 미팅 진행", + "content_md": "", + "order_no": 5, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 12, + "fields": { + "card": 5, + "title": "각자 생각한 서비스/프로젝트 아이디어 공유", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 13, + "fields": { + "card": 5, + "title": "현실성/기간 고려해 후보 압축", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 6, + "fields": { + "role": 1, + "title": "6. 프로젝트 방향 결정", + "content_md": "", + "order_no": 6, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 14, + "fields": { + "card": 6, + "title": "서비스 목표 (학습용 / 포폴 / 출시)", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 15, + "fields": { + "card": 6, + "title": "MVP 범위 합의", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 7, + "fields": { + "role": 1, + "title": "7. 기획 문서 작성", + "content_md": "", + "order_no": 7, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 16, + "fields": { + "card": 7, + "title": "서비스 개요", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 17, + "fields": { + "card": 7, + "title": "주요 기능", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 18, + "fields": { + "card": 7, + "title": "사용자 흐름 정리", + "description": "검색 키워드: 서비스 기획서 작성 양식", + "order_no": 3, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 8, + "fields": { + "role": 1, + "title": "8. 팀원 피드백 반영", + "content_md": "", + "order_no": 8, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 19, + "fields": { + "card": 8, + "title": "프론트/백 의견 수렴", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 20, + "fields": { + "card": 8, + "title": "기술적으로 어려운 부분 조정", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 9, + "fields": { + "role": 1, + "title": "9. 역할별 업무 범위 정리", + "content_md": "", + "order_no": 9, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 21, + "fields": { + "card": 9, + "title": "프론트 / 백 / 기타 역할 명확화", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 22, + "fields": { + "card": 9, + "title": "누가 무엇을 언제까지 할지 정리", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 10, + "fields": { + "role": 1, + "title": "10. 진행 상황 주기적 체크", + "content_md": "", + "order_no": 10, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 23, + "fields": { + "card": 10, + "title": "일정 밀리는 부분 확인", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 24, + "fields": { + "card": 10, + "title": "병목 발생 시 우선순위 재조정", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 11, + "fields": { + "role": 1, + "title": "11. 팀 분위기 관리", + "content_md": "", + "order_no": 11, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 25, + "fields": { + "card": 11, + "title": "진행 중 어려움 공유", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 26, + "fields": { + "card": 11, + "title": "중간 목표 달성 시 간단한 피드백", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 12, + "fields": { + "role": 1, + "title": "12. 결과물 활용 방향 논의", + "content_md": "", + "order_no": 12, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 27, + "fields": { + "card": 12, + "title": "배포 여부", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 28, + "fields": { + "card": 12, + "title": "포트폴리오 정리", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 29, + "fields": { + "card": 12, + "title": "홍보 방식 논의", + "description": "오픈채팅 / SNS / 커뮤니티 등", + "order_no": 3, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 13, + "fields": { + "role": 2, + "title": "1. 팀 커뮤니케이션 채널 참여", + "content_md": "", + "order_no": 1, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 30, + "fields": { + "card": 13, + "title": "PM이 공유한 팀플 채널 입장", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 14, + "fields": { + "role": 2, + "title": "2. 기획 내용 숙지", + "content_md": "", + "order_no": 2, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 31, + "fields": { + "card": 14, + "title": "기획 문서 읽고 전체 흐름 파악", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 32, + "fields": { + "card": 14, + "title": "핵심 사용자 화면 정리", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 15, + "fields": { + "role": 2, + "title": "3. 디자인 컨셉 리서치", + "content_md": "", + "order_no": 3, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 33, + "fields": { + "card": 15, + "title": "유사 서비스/디자인 레퍼런스 조사", + "description": "검색 키워드: Pinterest UI reference", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 16, + "fields": { + "role": 2, + "title": "4. 레퍼런스 공유 및 합의", + "content_md": "", + "order_no": 4, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 34, + "fields": { + "card": 16, + "title": "팀원들에게 디자인 방향 공유", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 35, + "fields": { + "card": 16, + "title": "피드백 반영해 방향 확정", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 17, + "fields": { + "role": 2, + "title": "5. 디자인 도구 선택", + "content_md": "", + "order_no": 5, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 36, + "fields": { + "card": 17, + "title": "Figma / Adobe XD / 기타 도구 선택", + "description": "검색 키워드: Figma 사용법", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 18, + "fields": { + "role": 2, + "title": "6. 컬러·폰트 가이드 정리", + "content_md": "", + "order_no": 6, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 37, + "fields": { + "card": 18, + "title": "컬러칩", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 38, + "fields": { + "card": 18, + "title": "기본 폰트", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 39, + "fields": { + "card": 18, + "title": "버튼/텍스트 스타일", + "description": "", + "order_no": 3, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 19, + "fields": { + "role": 2, + "title": "7. 와이어프레임 제작", + "content_md": "", + "order_no": 7, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 40, + "fields": { + "card": 19, + "title": "주요 화면 구조 설계", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 41, + "fields": { + "card": 19, + "title": "사용자 흐름 중심으로 구성", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 20, + "fields": { + "role": 2, + "title": "8. 와이어프레임 리뷰", + "content_md": "", + "order_no": 8, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 42, + "fields": { + "card": 20, + "title": "팀원 피드백 반영", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 43, + "fields": { + "card": 20, + "title": "수정사항 반영", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 21, + "fields": { + "role": 2, + "title": "9. 최종 UI 디자인 완성", + "content_md": "", + "order_no": 9, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 44, + "fields": { + "card": 21, + "title": "색상/아이콘/여백 적용", + "description": "검색 키워드: 사용자 편의성 UI 디자인", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 22, + "fields": { + "role": 2, + "title": "10. 퍼블리싱 또는 프론트 구현", + "content_md": "", + "order_no": 10, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 45, + "fields": { + "card": 22, + "title": "HTML/CSS 또는 React/Vue 등 선택", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 46, + "fields": { + "card": 22, + "title": "컴포넌트 단위로 구현", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 23, + "fields": { + "role": 2, + "title": "11. 백엔드 연동 고려", + "content_md": "", + "order_no": 11, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 47, + "fields": { + "card": 23, + "title": "데이터 위치/형식 파악", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 48, + "fields": { + "card": 23, + "title": "API 연동 포인트 확인", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 24, + "fields": { + "role": 2, + "title": "12. 반응형/기본 UX 점검", + "content_md": "", + "order_no": 12, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 49, + "fields": { + "card": 24, + "title": "모바일/데스크톱 기본 대응", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 50, + "fields": { + "card": 24, + "title": "버튼/폼 동작 확인", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 25, + "fields": { + "role": 2, + "title": "13. UI 수정 및 정리", + "content_md": "", + "order_no": 13, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 51, + "fields": { + "card": 25, + "title": "실제 사용 시 불편한 부분 개선", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 26, + "fields": { + "role": 3, + "title": "1. 기획 및 기능 범위 파악", + "content_md": "", + "order_no": 1, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 52, + "fields": { + "card": 26, + "title": "어떤 기능을 서버에서 담당하는지 확인", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 27, + "fields": { + "role": 3, + "title": "2. 기술 스택 결정", + "content_md": "", + "order_no": 2, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 53, + "fields": { + "card": 27, + "title": "Django / Spring / Node 등 선택", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 54, + "fields": { + "card": 27, + "title": "DB 종류 선택", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 28, + "fields": { + "role": 3, + "title": "3. 프로젝트 초기 세팅", + "content_md": "", + "order_no": 3, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 55, + "fields": { + "card": 28, + "title": "서버 프로젝트 생성", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 56, + "fields": { + "card": 28, + "title": "기본 폴더 구조 정리", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 29, + "fields": { + "role": 3, + "title": "4. DB 모델 설계", + "content_md": "", + "order_no": 4, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 57, + "fields": { + "card": 29, + "title": "핵심 엔티티 정의", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 58, + "fields": { + "card": 29, + "title": "관계 설정", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 30, + "fields": { + "role": 3, + "title": "5. 기본 CRUD 설계", + "content_md": "", + "order_no": 5, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 59, + "fields": { + "card": 30, + "title": "생성 / 조회 / 수정 / 삭제 흐름 정리", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 31, + "fields": { + "role": 3, + "title": "6. API 구조 설계", + "content_md": "", + "order_no": 6, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 60, + "fields": { + "card": 31, + "title": "엔드포인트 네이밍", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 61, + "fields": { + "card": 31, + "title": "요청/응답 형식 정의", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 32, + "fields": { + "role": 3, + "title": "7. 인증/권한 고려", + "content_md": "", + "order_no": 7, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 62, + "fields": { + "card": 32, + "title": "로그인 여부", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 63, + "fields": { + "card": 32, + "title": "사용자별 접근 제한", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 33, + "fields": { + "role": 3, + "title": "8. API 구현", + "content_md": "", + "order_no": 8, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 64, + "fields": { + "card": 33, + "title": "기능 단위로 구현", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 65, + "fields": { + "card": 33, + "title": "예외 상황 처리", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 34, + "fields": { + "role": 3, + "title": "9. API 테스트", + "content_md": "", + "order_no": 9, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 66, + "fields": { + "card": 34, + "title": "Postman / curl / 테스트 코드로 요청 확인", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 67, + "fields": { + "card": 34, + "title": "정상/에러 케이스 점검", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 35, + "fields": { + "role": 3, + "title": "10. 프론트 연동 지원", + "content_md": "", + "order_no": 10, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 68, + "fields": { + "card": 35, + "title": "프론트 요청 사항 반영", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 69, + "fields": { + "card": 35, + "title": "데이터 형식 조정", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 36, + "fields": { + "role": 3, + "title": "11. 환경 설정 분리", + "content_md": "", + "order_no": 11, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 70, + "fields": { + "card": 36, + "title": "로컬 / 배포 환경 구분", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 37, + "fields": { + "role": 3, + "title": "12. 배포 준비", + "content_md": "", + "order_no": 12, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 71, + "fields": { + "card": 37, + "title": "서버 실행 방식 정리", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 72, + "fields": { + "card": 37, + "title": "도메인/HTTPS 고려", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideCard", + "pk": 38, + "fields": { + "role": 3, + "title": "13. 기능 안정화", + "content_md": "", + "order_no": 13, + "is_active": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 73, + "fields": { + "card": 38, + "title": "에러 로그 확인", + "description": "", + "order_no": 1, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + }, + { + "model": "guides.GuideTask", + "pk": 74, + "fields": { + "card": 38, + "title": "성능/보안 기본 점검", + "description": "", + "order_no": 2, + "is_required": true, + "created_at": "2026-02-06T00:00:00Z", + "updated_at": "2026-02-06T00:00:00Z" + } + } +] \ No newline at end of file diff --git a/apps/guides/management/commands/load_guides.py b/apps/guides/management/commands/load_guides.py new file mode 100644 index 0000000..82ec1c3 --- /dev/null +++ b/apps/guides/management/commands/load_guides.py @@ -0,0 +1,36 @@ +import json +from django.core.management.base import BaseCommand +from django.core.management import call_command + + +class Command(BaseCommand): + help = 'JSON 파일에서 가이드 데이터를 로드합니다' + + def add_arguments(self, parser): + parser.add_argument( + '--reset', + action='store_true', + help='기존 가이드 데이터를 먼저 삭제합니다', + ) + + def handle(self, *args, **options): + # 기존 데이터 삭제 옵션 + if options['reset']: + from apps.guides.models import GuideCard, GuideTask + GuideCard.objects.all().delete() + GuideTask.objects.all().delete() + self.stdout.write(self.style.WARNING('기존 가이드 데이터를 삭제했습니다')) + + # fixture 로드 (순서 중요: roles -> guides) + try: + # 1. roles 먼저 로드 (FK 의존성) + call_command('loaddata', 'roles') + self.stdout.write(self.style.SUCCESS('✅ Roles 데이터 로드됨')) + + # 2. guides 로드 + call_command('loaddata', 'guides') + self.stdout.write( + self.style.SUCCESS('✅ 가이드 데이터를 성공적으로 로드했습니다!') + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f'❌ 오류: {e}')) diff --git a/apps/guides/migrations/0001_initial.py b/apps/guides/migrations/0001_initial.py new file mode 100644 index 0000000..23bcb22 --- /dev/null +++ b/apps/guides/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 5.2.10 on 2026-01-30 05:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='GuideTaskProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_completed', models.BooleanField(default=False)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'guide_task_progress', + }, + ), + migrations.CreateModel( + name='GuideStage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(help_text='단계 코드 (예: S01_KICKOFF, S02_ERD)', max_length=40, unique=True)), + ('title', models.CharField(help_text='단계 제목', max_length=120)), + ('description', models.TextField(blank=True, help_text='단계 설명', null=True)), + ('order_no', models.IntegerField(default=0, help_text='정렬 순서')), + ('is_active', models.BooleanField(default=True, help_text='활성화 여부')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'guide_stages', + 'ordering': ['order_no'], + 'indexes': [models.Index(fields=['order_no'], name='guide_stage_order_n_5f6b90_idx'), models.Index(fields=['is_active'], name='guide_stage_is_acti_27d912_idx')], + }, + ), + migrations.CreateModel( + name='GuideCard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='카드 제목', max_length=120)), + ('content_md', models.TextField(help_text='상세 가이드 내용 (마크다운)')), + ('order_no', models.IntegerField(default=0, help_text='정렬 순서')), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('role', models.ForeignKey(help_text='대상 역할 (PM/FRONTEND/BACKEND)', on_delete=django.db.models.deletion.CASCADE, related_name='guide_cards', to='accounts.role')), + ('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='guides.guidestage')), + ], + options={ + 'db_table': 'guide_cards', + 'ordering': ['stage', 'role', 'order_no'], + }, + ), + migrations.CreateModel( + name='GuideTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='태스크 제목', max_length=140)), + ('description', models.TextField(blank=True, help_text='태스크 상세 설명', null=True)), + ('order_no', models.IntegerField(default=0, help_text='정렬 순서')), + ('is_required', models.BooleanField(default=True, help_text='필수 여부')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='guides.guidecard')), + ], + options={ + 'db_table': 'guide_tasks', + 'ordering': ['card', 'order_no'], + }, + ), + ] diff --git a/apps/guides/migrations/0002_initial.py b/apps/guides/migrations/0002_initial.py new file mode 100644 index 0000000..0428089 --- /dev/null +++ b/apps/guides/migrations/0002_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.10 on 2026-01-30 05:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('guides', '0001_initial'), + ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='guidetaskprogress', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_progress', to='projects.project'), + ), + migrations.AddField( + model_name='guidetaskprogress', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='guides.guidetask'), + ), + migrations.AddField( + model_name='guidetaskprogress', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_progress', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='guidecard', + index=models.Index(fields=['stage', 'role', 'order_no'], name='guide_cards_stage_i_132d3c_idx'), + ), + migrations.AddIndex( + model_name='guidecard', + index=models.Index(fields=['is_active'], name='guide_cards_is_acti_e632b4_idx'), + ), + migrations.AddIndex( + model_name='guidetask', + index=models.Index(fields=['card', 'order_no'], name='guide_tasks_card_id_17746d_idx'), + ), + migrations.AddIndex( + model_name='guidetaskprogress', + index=models.Index(fields=['project', 'user'], name='guide_task__project_5c19f5_idx'), + ), + migrations.AddConstraint( + model_name='guidetaskprogress', + constraint=models.UniqueConstraint(fields=('task', 'project', 'user'), name='uq_task_project_user'), + ), + ] diff --git a/apps/guides/migrations/0003_projectprogress_remove_guidecard_stage_and_more.py b/apps/guides/migrations/0003_projectprogress_remove_guidecard_stage_and_more.py new file mode 100644 index 0000000..9b925ef --- /dev/null +++ b/apps/guides/migrations/0003_projectprogress_remove_guidecard_stage_and_more.py @@ -0,0 +1,106 @@ +# Generated by Django 5.2.10 on 2026-02-06 04:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0006_techstack_user_tech_stacks'), + ('guides', '0002_initial'), + ('projects', '0004_remove_project_current_stage'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('completed_tasks', models.IntegerField(default=0, help_text='완료한 태스크 수')), + ('total_tasks', models.IntegerField(default=0, help_text='전체 태스크 수')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'project_progress', + }, + ), + migrations.RemoveField( + model_name='guidecard', + name='stage', + ), + migrations.AlterModelOptions( + name='guidecard', + options={'ordering': ['role', 'order_no']}, + ), + migrations.RemoveConstraint( + model_name='guidetaskprogress', + name='uq_task_project_user', + ), + migrations.RemoveIndex( + model_name='guidecard', + name='guide_cards_stage_i_132d3c_idx', + ), + migrations.RemoveIndex( + model_name='guidetaskprogress', + name='guide_task__project_5c19f5_idx', + ), + migrations.RemoveField( + model_name='guidetaskprogress', + name='user', + ), + migrations.AlterField( + model_name='guidecard', + name='content_md', + field=models.TextField(help_text='미션 설명 (마크다운)'), + ), + migrations.AlterField( + model_name='guidecard', + name='order_no', + field=models.IntegerField(default=0, help_text='역할별 미션 순서'), + ), + migrations.AlterField( + model_name='guidecard', + name='title', + field=models.CharField(help_text='미션 제목', max_length=120), + ), + migrations.AlterField( + model_name='guidetaskprogress', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='guide_task_progress', to='projects.project'), + ), + migrations.AddIndex( + model_name='guidecard', + index=models.Index(fields=['role', 'order_no'], name='guide_cards_role_id_05238e_idx'), + ), + migrations.AddIndex( + model_name='guidetaskprogress', + index=models.Index(fields=['project'], name='guide_task__project_814d38_idx'), + ), + migrations.AddIndex( + model_name='guidetaskprogress', + index=models.Index(fields=['task'], name='guide_task__task_id_5123d1_idx'), + ), + migrations.AddConstraint( + model_name='guidetaskprogress', + constraint=models.UniqueConstraint(fields=('task', 'project'), name='uq_task_project'), + ), + migrations.AddField( + model_name='projectprogress', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_progress', to='projects.project'), + ), + migrations.AddField( + model_name='projectprogress', + name='role', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_progress', to='accounts.role'), + ), + migrations.DeleteModel( + name='GuideStage', + ), + migrations.AddConstraint( + model_name='projectprogress', + constraint=models.UniqueConstraint(fields=('project', 'role'), name='uq_project_role'), + ), + ] diff --git a/apps/guides/migrations/0004_guidetask_updated_at.py b/apps/guides/migrations/0004_guidetask_updated_at.py new file mode 100644 index 0000000..efbd489 --- /dev/null +++ b/apps/guides/migrations/0004_guidetask_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-06 09:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('guides', '0003_projectprogress_remove_guidecard_stage_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='guidetask', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/apps/guides/migrations/__init__.py b/apps/guides/migrations/__init__.py new file mode 100644 index 0000000..8392b3c --- /dev/null +++ b/apps/guides/migrations/__init__.py @@ -0,0 +1 @@ +# Generated by Django - will be auto-generated diff --git a/apps/guides/models.py b/apps/guides/models.py new file mode 100644 index 0000000..349841c --- /dev/null +++ b/apps/guides/models.py @@ -0,0 +1,211 @@ +from django.conf import settings +from django.db import models +from django.db.models import Count, Q + + +class GuideCard(models.Model): + """ + 역할별 미션 카드 + - 순차적으로 진행되는 미션 + - 역할(PM/FE/BE)별로 개별 미션 제공 + """ + + role = models.ForeignKey( + "accounts.Role", + on_delete=models.CASCADE, + related_name="guide_cards", + help_text="대상 역할 (PM/FRONTEND/BACKEND)", + ) + + title = models.CharField( + max_length=120, + help_text="미션 제목", + ) + + content_md = models.TextField( + help_text="미션 설명 (마크다운)", + ) + + order_no = models.IntegerField( + default=0, + help_text="역할별 미션 순서", + ) + + is_active = models.BooleanField( + default=True, + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "guide_cards" + ordering = ["role", "order_no"] + indexes = [ + models.Index(fields=["role", "order_no"]), + models.Index(fields=["is_active"]), + ] + + def __str__(self) -> str: + return f"{self.role.code} 미션 {self.order_no}: {self.title}" + + def get_progress(self, project) -> dict: + """프로젝트별 이 미션의 완료율""" + tasks = self.tasks.all() + completed = tasks.filter( + progress__project=project, + progress__is_completed=True + ).distinct().count() + total = tasks.count() + + return { + "completed": completed, + "total": total, + "percent": int((completed / total * 100) if total > 0 else 0), + } + + +class GuideTask(models.Model): + """ + 가이드 태스크 (체크리스트 항목) + - 각 미션(카드)에 포함된 세부 할 일 + """ + + card = models.ForeignKey( + GuideCard, + on_delete=models.CASCADE, + related_name="tasks", + ) + + title = models.CharField( + max_length=140, + help_text="태스크 제목", + ) + + description = models.TextField( + null=True, + blank=True, + help_text="태스크 상세 설명", + ) + + order_no = models.IntegerField( + default=0, + help_text="정렬 순서", + ) + + is_required = models.BooleanField( + default=True, + help_text="필수 여부", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "guide_tasks" + ordering = ["card", "order_no"] + indexes = [ + models.Index(fields=["card", "order_no"]), + ] + + def __str__(self) -> str: + return f"{self.card.title} - {self.title}" + + +class GuideTaskProgress(models.Model): + """ + 가이드 태스크 진행 상황 + - 프로젝트 × 태스크 별 완료 여부 + - 역할별 진척도를 추적 + """ + + task = models.ForeignKey( + GuideTask, + on_delete=models.CASCADE, + related_name="progress", + ) + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="guide_task_progress", + ) + + is_completed = models.BooleanField( + default=False, + ) + + completed_at = models.DateTimeField( + null=True, + blank=True, + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "guide_task_progress" + constraints = [ + models.UniqueConstraint( + fields=["task", "project"], + name="uq_task_project", + ), + ] + indexes = [ + models.Index(fields=["project"]), + models.Index(fields=["task"]), + ] + + def __str__(self) -> str: + status = "✓" if self.is_completed else "○" + return f"{status} {self.task.title} ({self.project})" + + +class ProjectProgress(models.Model): + """ + 프로젝트 역할별 진척도 + - 각 역할(PM/FE/BE)의 미션 진행 상황 요약 + """ + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="role_progress", + ) + + role = models.ForeignKey( + "accounts.Role", + on_delete=models.CASCADE, + related_name="project_progress", + ) + + completed_tasks = models.IntegerField( + default=0, + help_text="완료한 태스크 수", + ) + + total_tasks = models.IntegerField( + default=0, + help_text="전체 태스크 수", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "project_progress" + constraints = [ + models.UniqueConstraint( + fields=["project", "role"], + name="uq_project_role", + ), + ] + + def __str__(self) -> str: + percent = int((self.completed_tasks / self.total_tasks * 100) if self.total_tasks > 0 else 0) + return f"{self.project} - {self.role.code}: {percent}%" + + @property + def progress_percent(self) -> int: + """진척도 퍼센트""" + return int((self.completed_tasks / self.total_tasks * 100) if self.total_tasks > 0 else 0) diff --git a/apps/guides/services.py b/apps/guides/services.py new file mode 100644 index 0000000..4c17e92 --- /dev/null +++ b/apps/guides/services.py @@ -0,0 +1,96 @@ +""" +가이드 관련 비즈니스 로직 +""" +import markdown +import bleach +from django.db.models import Count + +from .models import GuideCard, GuideTask, GuideTaskProgress, ProjectProgress + + +class GuideService: + """가이드 서비스""" + + @staticmethod + def render_markdown(content): + """마크다운을 HTML로 변환 (XSS 방지)""" + if not content: + return "" + + html = markdown.markdown( + content, + extensions=['tables', 'fenced_code', 'nl2br', 'toc'] + ) + + # XSS 방지: 안전한 태그만 허용 + allowed_tags = [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'strong', 'em', 'u', 'del', + 'ul', 'ol', 'li', + 'blockquote', + 'code', 'pre', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'a', 'img', + ] + + allowed_attributes = { + 'a': ['href', 'title', 'target'], + 'img': ['src', 'alt', 'title'], + 'code': ['class'], + } + + html = bleach.clean(html, tags=allowed_tags, attributes=allowed_attributes) + return html + + @staticmethod + def get_role_progress(project, role): + """ + 역할별 진척도 조회 + + Returns: + { + 'role': Role, + 'completed_tasks': int, + 'total_tasks': int, + 'progress_percent': int, + } + """ + guide_cards = GuideCard.objects.filter(role=role, is_active=True) + + total_tasks = 0 + completed_tasks = 0 + + for card in guide_cards: + tasks = card.tasks.all() + total_tasks += tasks.count() + + completed = GuideTaskProgress.objects.filter( + task__in=tasks, + project=project, + is_completed=True + ).count() + completed_tasks += completed + + progress_percent = int((completed_tasks / total_tasks * 100) if total_tasks > 0 else 0) + + return { + 'role': role, + 'completed_tasks': completed_tasks, + 'total_tasks': total_tasks, + 'progress_percent': progress_percent, + } + + @staticmethod + def get_all_role_progress(project): + """프로젝트의 모든 역할 진척도""" + progress_list = [] + + for proj_progress in ProjectProgress.objects.filter(project=project): + progress_list.append({ + 'role': proj_progress.role, + 'completed_tasks': proj_progress.completed_tasks, + 'total_tasks': proj_progress.total_tasks, + 'progress_percent': proj_progress.progress_percent, + }) + + return progress_list diff --git a/apps/guides/tests.py b/apps/guides/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/guides/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/guides/urls.py b/apps/guides/urls.py new file mode 100644 index 0000000..63b1081 --- /dev/null +++ b/apps/guides/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +app_name = "guides" + +urlpatterns = [ + path("mission/", views.mission, name="mission"), +] \ No newline at end of file diff --git a/apps/guides/views.py b/apps/guides/views.py new file mode 100644 index 0000000..8ddbe30 --- /dev/null +++ b/apps/guides/views.py @@ -0,0 +1,119 @@ +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required + +from apps.projects.models import Project +from apps.teams.models import TeamMember +from apps.accounts.models import Role +from .models import GuideCard, GuideTaskProgress, ProjectProgress + + +@login_required +def mission(request): + """미션 페이지""" + project = Project.objects.filter( + team__members__user=request.user, + team__members__is_active=True + ).first() + + if not project: + return render(request, "guides/mission.html", {"project": None}) + + team_member = get_object_or_404( + TeamMember, + team=project.team, + user=request.user, + is_active=True + ) + role = team_member.role + + # 역할별 미션 카드 + guide_cards = GuideCard.objects.filter( + role=role, + is_active=True + ).order_by('order_no').prefetch_related('tasks') + + # 미션 데이터 구성 + mission_data = [] + for card in guide_cards: + tasks = card.tasks.all() + + # 완료된 태스크 개수 + completed_count = GuideTaskProgress.objects.filter( + task__in=tasks, + project=project, + is_completed=True + ).count() + + # 카드 완료 여부 + is_card_completed = completed_count == tasks.count() if tasks.count() > 0 else False + + task_progress_data = [] + for task in tasks: + progress = GuideTaskProgress.objects.filter( + task=task, + project=project + ).first() + + task_progress_data.append({ + 'task': task, + 'is_completed': progress.is_completed if progress else False, + }) + + mission_data.append({ + 'card': card, + 'task_progress_data': task_progress_data, + 'is_completed': is_card_completed, + }) + + # 모든 역할의 진척도 계산 + all_role_progress = [] + + # 프로젝트에 속한 모든 팀원의 역할 가져오기 + team_members_data = TeamMember.objects.filter( + team=project.team, + is_active=True + ).values('role').distinct() + + team_role_ids = [member['role'] for member in team_members_data] + team_roles = Role.objects.filter(id__in=team_role_ids) + + for team_role in team_roles: + # 해당 역할의 모든 미션 카드 + cards = GuideCard.objects.filter( + role=team_role, + is_active=True + ).prefetch_related('tasks') + + # 전체 태스크 수 및 완료된 태스크 수 + total_tasks = 0 + completed_tasks = 0 + + for card in cards: + for task in card.tasks.all(): + total_tasks += 1 + # 이 태스크가 프로젝트에서 완료되었는지 확인 + is_completed = GuideTaskProgress.objects.filter( + task=task, + project=project, + is_completed=True + ).exists() + + if is_completed: + completed_tasks += 1 + + progress_percent = int((completed_tasks / total_tasks * 100) if total_tasks > 0 else 0) + + all_role_progress.append({ + 'role': team_role, + 'total_tasks': total_tasks, + 'completed_tasks': completed_tasks, + 'progress_percent': progress_percent, + }) + + context = { + 'project': project, + 'role': role, + 'mission_data': mission_data, + 'all_role_progress': all_role_progress, + } + return render(request, "guides/mission.html", context) \ No newline at end of file diff --git a/apps/projects/__init__.py b/apps/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/admin.py b/apps/projects/admin.py new file mode 100644 index 0000000..4018188 --- /dev/null +++ b/apps/projects/admin.py @@ -0,0 +1,166 @@ +from django.contrib import admin +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.utils.translation import ngettext + +from .models import Season, Project +from .services import TeamMatchingService, EmailService + + +@admin.register(Season) +class SeasonAdmin(admin.ModelAdmin): + list_display = ["name", "status", "matching_start", "matching_end", "project_start", "project_end"] + list_filter = ["status", "created_at"] + search_fields = ["name"] + ordering = ["-created_at"] + actions = ["run_team_matching", "send_matching_start_email", "send_matching_results_email"] + + def run_team_matching(self, request, queryset): + """팀 매칭 알고리즘 실행""" + for season in queryset: + try: + result = TeamMatchingService.run_matching(season.id) + self.message_user( + request, + f"✅ [{season.name}] 팀 매칭 완료: " + f"{result['teams_created']}팀 생성, " + f"{result['total_users_matched']}명 매칭", + messages.SUCCESS, + ) + except ValidationError as e: + self.message_user( + request, + f"❌ [{season.name}] 팀 매칭 실패: {str(e)}", + messages.ERROR, + ) + except Exception as e: + self.message_user( + request, + f"❌ [{season.name}] 팀 매칭 오류: {str(e)}", + messages.ERROR, + ) + + def send_matching_results_email(self, request, queryset): + """팀 매칭 결과 이메일 발송""" + for season in queryset: + try: + result = EmailService.send_matching_results(season.id) + self.message_user( + request, + f"📧 [{season.name}] 팀 매칭 결과 이메일 발송 완료: " + f"{result['sent_count']}개 팀 / {result['failed_count']}개 실패", + messages.SUCCESS, + ) + except Exception as e: + self.message_user( + request, + f"❌ [{season.name}] 이메일 발송 오류: {str(e)}", + messages.ERROR, + ) + + def send_matching_start_email(self, request, queryset): + """팀 매칭 기간 시작 알림 이메일 발송""" + for season in queryset: + try: + result = EmailService.send_matching_start_notification(season.id) + self.message_user( + request, + f"📧 [{season.name}] 팀 매칭 시작 알림 이메일 발송 완료: " + f"{result['sent_count']}명 / {result['failed_count']}명 실패", + messages.SUCCESS, + ) + except Exception as e: + self.message_user( + request, + f"❌ [{season.name}] 이메일 발송 오류: {str(e)}", + messages.ERROR, + ) + + run_team_matching.short_description = "🤝 팀 매칭 알고리즘 실행" + send_matching_start_email.short_description = "📢 팀 매칭 시작 알림 이메일 발송" + send_matching_results_email.short_description = "📧 팀 매칭 결과 이메일 발송" + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + list_display = ["id", "title", "owner", "status", "duration_weeks", "created_at"] + list_filter = ["status", "created_at"] + search_fields = ["title", "description"] + ordering = ["-created_at"] + actions = [ + "change_status_to_open", + "change_status_to_matched", + "change_status_to_in_progress", + "change_status_to_completed", + "change_status_to_archived", + ] + + def change_status_to_open(self, request, queryset): + """상태 변경: 모집중""" + count = queryset.update(status=Project.Status.OPEN) + self.message_user( + request, + ngettext( + f"{count}개 프로젝트가 '모집중' 상태로 변경되었습니다.", + f"{count}개 프로젝트가 '모집중' 상태로 변경되었습니다.", + count, + ), + messages.SUCCESS, + ) + change_status_to_open.short_description = "📢 상태 변경: 모집중" + + def change_status_to_matched(self, request, queryset): + """상태 변경: 매칭완료""" + count = queryset.update(status=Project.Status.MATCHED) + self.message_user( + request, + ngettext( + f"{count}개 프로젝트가 '매칭완료' 상태로 변경되었습니다.", + f"{count}개 프로젝트가 '매칭완료' 상태로 변경되었습니다.", + count, + ), + messages.SUCCESS, + ) + change_status_to_matched.short_description = "✅ 상태 변경: 매칭완료" + + def change_status_to_in_progress(self, request, queryset): + """상태 변경: 진행중""" + count = queryset.update(status=Project.Status.IN_PROGRESS) + self.message_user( + request, + ngettext( + f"{count}개 프로젝트가 '진행중' 상태로 변경되었습니다.", + f"{count}개 프로젝트가 '진행중' 상태로 변경되었습니다.", + count, + ), + messages.SUCCESS, + ) + change_status_to_in_progress.short_description = "⚙️ 상태 변경: 진행중" + + def change_status_to_completed(self, request, queryset): + """상태 변경: 완료""" + count = queryset.update(status=Project.Status.COMPLETED) + self.message_user( + request, + ngettext( + f"{count}개 프로젝트가 '완료' 상태로 변경되었습니다.", + f"{count}개 프로젝트가 '완료' 상태로 변경되었습니다.", + count, + ), + messages.SUCCESS, + ) + change_status_to_completed.short_description = "🎉 상태 변경: 완료" + + def change_status_to_archived(self, request, queryset): + """상태 변경: 보관됨""" + count = queryset.update(status=Project.Status.ARCHIVED) + self.message_user( + request, + ngettext( + f"{count}개 프로젝트가 '보관됨' 상태로 변경되었습니다.", + f"{count}개 프로젝트가 '보관됨' 상태로 변경되었습니다.", + count, + ), + messages.SUCCESS, + ) + change_status_to_archived.short_description = "📦 상태 변경: 보관됨" diff --git a/apps/projects/api_urls.py b/apps/projects/api_urls.py new file mode 100644 index 0000000..1d43b7c --- /dev/null +++ b/apps/projects/api_urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +urlpatterns = [ + # 프로젝트 좋아요 토글 + path("/like/", views.toggle_project_like, name="api_toggle_project_like"), +] diff --git a/apps/projects/apps.py b/apps/projects/apps.py new file mode 100644 index 0000000..e55710a --- /dev/null +++ b/apps/projects/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.projects" + verbose_name = "프로젝트" diff --git a/apps/projects/forms.py b/apps/projects/forms.py new file mode 100644 index 0000000..e15ac87 --- /dev/null +++ b/apps/projects/forms.py @@ -0,0 +1,60 @@ +from django import forms +from .models import Project + + +class ProjectDashboardEditForm(forms.ModelForm): + """ + 프로젝트 대시보드 수정 폼 + 팀원이 수정 가능한 필드만 포함 + """ + + class Meta: + model = Project + fields = [ + "title", # 서비스명 + "description", # 서비스 소개 + "project_image", # 프로젝트 프로필 사진 + "team_rules", # 팀 규칙 + "related_links", # 관련 링크 + ] + widgets = { + "title": forms.TextInput(attrs={ + "class": "form-control", + "placeholder": "서비스명을 입력하세요", + "maxlength": "120", + }), + "description": forms.Textarea(attrs={ + "class": "form-control", + "placeholder": "서비스에 대해 설명해주세요", + "rows": 4, + }), + "project_image": forms.FileInput(attrs={ + "class": "form-control", + "accept": "image/*", + }), + "team_rules": forms.Textarea(attrs={ + "class": "form-control", + "placeholder": "팀 규칙을 마크다운으로 작성해주세요\n\n예:\n# 회의\n- 주 1회 수요일 19시\n- 지각 3회 = 경고\n\n# 코드 리뷰\n- PR 2시간 내 리뷰\n- 2명 승인 필수", + "rows": 6, + }), + "related_links": forms.Textarea(attrs={ + "class": "form-control", + "placeholder": "관련 링크를 마크다운으로 입력해주세요\n\n예:\n[Notion](https://notion.so/...)\n[Figma](https://figma.com/...)\n[GitHub](https://github.com/...)", + "rows": 6, + }), + } + + def clean_title(self): + """서비스명 유효성 검사""" + title = self.cleaned_data.get("title", "").strip() + if not title: + raise forms.ValidationError("서비스명은 필수입니다.") + return title + + def clean_related_links(self): + """관련 링크 정리 - "{}" 같은 빈 값 제거""" + related_links = self.cleaned_data.get("related_links", "").strip() + # "{}" 또는 빈 문자열이면 None 반환 + if not related_links or related_links == "{}": + return None + return related_links diff --git a/apps/projects/management/__init__.py b/apps/projects/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/management/commands/__init__.py b/apps/projects/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/management/commands/run_team_matching.py b/apps/projects/management/commands/run_team_matching.py new file mode 100644 index 0000000..19b2765 --- /dev/null +++ b/apps/projects/management/commands/run_team_matching.py @@ -0,0 +1,65 @@ +""" +팀 매칭 실행 management command +""" +from django.core.management.base import BaseCommand, CommandError +from django.core.exceptions import ValidationError + +from apps.projects.models import Season +from apps.projects.services import TeamMatchingService + + +class Command(BaseCommand): + help = '팀 매칭 알고리즘 실행' + + def add_arguments(self, parser): + parser.add_argument( + '--season-id', + type=int, + required=True, + help='Season ID', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='실제 저장하지 않고 결과만 미리 확인', + ) + + def handle(self, *args, **options): + season_id = options['season_id'] + + # Season 존재 확인 + try: + season = Season.objects.get(id=season_id) + except Season.DoesNotExist: + raise CommandError(f'Season ID {season_id}를 찾을 수 없습니다.') + + self.stdout.write(f'📌 시즌: {season.name}') + self.stdout.write(f'📌 상태: {season.status}') + + # 매칭 시간 확인 + if not season.is_matching_period(): + raise CommandError('현재 팀매칭 기간이 아닙니다.') + + if options['dry_run']: + self.stdout.write('⚠️ Dry-run 모드: 결과만 미리 확인합니다.') + + try: + result = TeamMatchingService.run_matching(season_id) + + self.stdout.write(self.style.SUCCESS('✅ 팀 매칭 완료!')) + self.stdout.write(f' - 생성된 팀: {result["teams_created"]}개') + self.stdout.write(f' - 매칭된 사용자: {result["total_users_matched"]}명') + self.stdout.write(f' • PM: {result["pm_matched"]}명') + self.stdout.write(f' • FE: {result["fe_matched"]}명') + self.stdout.write(f' • BE: {result["be_matched"]}명') + + if result['total_unmatched'] > 0: + self.stdout.write(self.style.WARNING(f'⚠️ 매칭 안 된 사용자: {result["total_unmatched"]}명')) + self.stdout.write(f' • PM: {result["unmatched"]["pm"]}명') + self.stdout.write(f' • FE: {result["unmatched"]["fe"]}명') + self.stdout.write(f' • BE: {result["unmatched"]["be"]}명') + + except ValidationError as e: + raise CommandError(f'❌ 매칭 실패: {e.message}') + except Exception as e: + raise CommandError(f'❌ 예상치 못한 오류: {str(e)}') diff --git a/apps/projects/migrations/0001_initial.py b/apps/projects/migrations/0001_initial.py new file mode 100644 index 0000000..576b701 --- /dev/null +++ b/apps/projects/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.10 on 2026-01-30 05:10 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('guides', '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')), + ('title', models.CharField(help_text='프로젝트 제목', max_length=120)), + ('description', models.TextField(blank=True, help_text='프로젝트 설명', null=True)), + ('duration_weeks', models.SmallIntegerField(default=6, help_text='프로젝트 기간 (주)', validators=[django.core.validators.MinValueValidator(1)])), + ('target_team_size', models.SmallIntegerField(default=5, help_text='목표 팀 인원 (PM1/FE2/BE2 = 5명)')), + ('status', models.CharField(choices=[('DRAFT', '임시저장'), ('OPEN', '모집중'), ('MATCHED', '매칭완료'), ('IN_PROGRESS', '진행중'), ('COMPLETED', '완료'), ('ARCHIVED', '보관됨')], default='OPEN', help_text='프로젝트 상태', max_length=20)), + ('starts_at', models.DateField(blank=True, help_text='시작 예정일', null=True)), + ('ends_at', models.DateField(blank=True, help_text='종료 예정일', null=True)), + ('region', models.CharField(blank=True, help_text='활동 지역 (오프라인 시)', max_length=80, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('current_stage', models.ForeignKey(blank=True, help_text='현재 진행 중인 가이드 단계', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='guides.guidestage')), + ('owner', models.ForeignKey(blank=True, help_text='프로젝트 생성자', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_projects', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'projects', + }, + ), + migrations.CreateModel( + name='ProjectApplication', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('passion_level', models.SmallIntegerField(help_text='열정 레벨 (1~4, 설문 결과)', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4)])), + ('status', models.CharField(choices=[('APPLIED', '지원됨'), ('CANCELLED', '취소됨'), ('MATCHED', '매칭됨'), ('REJECTED', '거절됨')], default='APPLIED', max_length=20)), + ('applied_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='projects.project')), + ('role', models.ForeignKey(help_text='지원 역할 (PM/FRONTEND/BACKEND)', on_delete=django.db.models.deletion.PROTECT, related_name='applications', to='accounts.role')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'project_applications', + }, + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['status'], name='projects_status_6303d7_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['created_at'], name='projects_created_40bcd1_idx'), + ), + migrations.AddIndex( + model_name='projectapplication', + index=models.Index(fields=['project', 'role', 'status'], name='project_app_project_6eb18d_idx'), + ), + migrations.AddIndex( + model_name='projectapplication', + index=models.Index(fields=['user', 'status'], name='project_app_user_id_49508c_idx'), + ), + migrations.AddIndex( + model_name='projectapplication', + index=models.Index(fields=['passion_level'], name='project_app_passion_afa48b_idx'), + ), + migrations.AddConstraint( + model_name='projectapplication', + constraint=models.UniqueConstraint(fields=('project', 'user'), name='uq_project_application'), + ), + migrations.AddConstraint( + model_name='projectapplication', + constraint=models.CheckConstraint(condition=models.Q(('passion_level__gte', 1), ('passion_level__lte', 4)), name='ck_passion_level_range'), + ), + ] diff --git a/apps/projects/migrations/0002_season.py b/apps/projects/migrations/0002_season.py new file mode 100644 index 0000000..9528195 --- /dev/null +++ b/apps/projects/migrations/0002_season.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.10 on 2026-02-01 14:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Season', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='시즌명 (예: 2026년 1월 시즌)', max_length=100)), + ('status', models.CharField(choices=[('UPCOMING', '예정'), ('MATCHING', '팀매칭 중'), ('IN_PROJECT', '프로젝트 진행 중'), ('ENDED', '종료')], default='UPCOMING', max_length=20)), + ('matching_start', models.DateTimeField(help_text='팀매칭 시작')), + ('matching_end', models.DateTimeField(help_text='팀매칭 종료')), + ('project_start', models.DateTimeField(help_text='프로젝트 시작')), + ('project_end', models.DateTimeField(help_text='프로젝트 종료')), + ('is_active', models.BooleanField(default=False, help_text='현재 진행 중인 시즌')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'seasons', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['is_active'], name='seasons_is_acti_2e3a86_idx'), models.Index(fields=['status'], name='seasons_status_dc032e_idx')], + }, + ), + ] diff --git a/apps/projects/migrations/0003_project_is_favorite_project_project_image_and_more.py b/apps/projects/migrations/0003_project_is_favorite_project_project_image_and_more.py new file mode 100644 index 0000000..f41a727 --- /dev/null +++ b/apps/projects/migrations/0003_project_is_favorite_project_project_image_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.10 on 2026-02-03 07:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_season'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='is_favorite', + field=models.BooleanField(default=False, help_text='즐겨찾기 여부'), + ), + migrations.AddField( + model_name='project', + name='project_image', + field=models.ImageField(blank=True, help_text='프로젝트 프로필 사진', null=True, upload_to='projects/'), + ), + migrations.AddField( + model_name='project', + name='related_links', + field=models.JSONField(blank=True, default=dict, help_text='관련 링크 (Notion, Figma, GitHub 등)'), + ), + migrations.AddField( + model_name='project', + name='team_rules', + field=models.TextField(blank=True, help_text='팀 규칙 (마크다운)', null=True), + ), + ] diff --git a/apps/projects/migrations/0004_remove_project_current_stage.py b/apps/projects/migrations/0004_remove_project_current_stage.py new file mode 100644 index 0000000..06cf870 --- /dev/null +++ b/apps/projects/migrations/0004_remove_project_current_stage.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.10 on 2026-02-06 04:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_project_is_favorite_project_project_image_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='project', + name='current_stage', + ), + ] diff --git a/apps/projects/migrations/0005_add_season_fk.py b/apps/projects/migrations/0005_add_season_fk.py new file mode 100644 index 0000000..f5561a3 --- /dev/null +++ b/apps/projects/migrations/0005_add_season_fk.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.10 on 2026-02-07 02:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_remove_project_current_stage'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='season', + field=models.ForeignKey(blank=True, help_text='속한 시즌', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='projects.season'), + ), + ] diff --git a/apps/projects/migrations/0006_alter_project_related_links.py b/apps/projects/migrations/0006_alter_project_related_links.py new file mode 100644 index 0000000..52c248d --- /dev/null +++ b/apps/projects/migrations/0006_alter_project_related_links.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-07 13:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0005_add_season_fk'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='related_links', + field=models.TextField(blank=True, help_text='관련 링크 (마크다운)', null=True), + ), + ] diff --git a/apps/projects/migrations/0007_clean_related_links.py b/apps/projects/migrations/0007_clean_related_links.py new file mode 100644 index 0000000..efa5f27 --- /dev/null +++ b/apps/projects/migrations/0007_clean_related_links.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.10 on 2026-02-07 13:23 + +from django.db import migrations + + +def clean_related_links(apps, schema_editor): + """"{}" 형식의 related_links를 None으로 변환""" + Project = apps.get_model('projects', 'Project') + Project.objects.filter(related_links='{}').update(related_links=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0006_alter_project_related_links'), + ] + + operations = [ + migrations.RunPython(clean_related_links), + ] diff --git a/apps/projects/migrations/0008_projectlike.py b/apps/projects/migrations/0008_projectlike.py new file mode 100644 index 0000000..d866f4c --- /dev/null +++ b/apps/projects/migrations/0008_projectlike.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.10 on 2026-02-10 07:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0007_clean_related_links'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ProjectLike', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('project', models.ForeignKey(help_text='좋아요 받은 프로젝트', on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='projects.project')), + ('user', models.ForeignKey(help_text='좋아요 누른 사용자', on_delete=django.db.models.deletion.CASCADE, related_name='project_likes', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'project_likes', + 'indexes': [models.Index(fields=['project'], name='project_lik_project_a701ec_idx'), models.Index(fields=['user'], name='project_lik_user_id_47c108_idx')], + 'unique_together': {('user', 'project')}, + }, + ), + ] diff --git a/apps/projects/migrations/0009_remove_projectapplication_project_and_more.py b/apps/projects/migrations/0009_remove_projectapplication_project_and_more.py new file mode 100644 index 0000000..4e576d4 --- /dev/null +++ b/apps/projects/migrations/0009_remove_projectapplication_project_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.10 on 2026-02-13 04:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0008_projectlike'), + ] + + operations = [ + migrations.RemoveField( + model_name='projectapplication', + name='project', + ), + migrations.RemoveField( + model_name='projectapplication', + name='role', + ), + migrations.RemoveField( + model_name='projectapplication', + name='user', + ), + migrations.RemoveIndex( + model_name='season', + name='seasons_is_acti_2e3a86_idx', + ), + migrations.RemoveField( + model_name='project', + name='is_favorite', + ), + migrations.RemoveField( + model_name='project', + name='target_team_size', + ), + migrations.RemoveField( + model_name='season', + name='is_active', + ), + migrations.DeleteModel( + name='ProjectApplication', + ), + ] diff --git a/apps/projects/migrations/__init__.py b/apps/projects/migrations/__init__.py new file mode 100644 index 0000000..8392b3c --- /dev/null +++ b/apps/projects/migrations/__init__.py @@ -0,0 +1 @@ +# Generated by Django - will be auto-generated diff --git a/apps/projects/models.py b/apps/projects/models.py new file mode 100644 index 0000000..3c29074 --- /dev/null +++ b/apps/projects/models.py @@ -0,0 +1,228 @@ +from django.conf import settings +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator +from django.utils import timezone + + +class Season(models.Model): + """ + 시즌 관리 + - 관리자가 팀매칭 기간과 프로젝트 기간을 설정 + - 여러 시즌 동시 운영 가능 + """ + + class Status(models.TextChoices): + UPCOMING = "UPCOMING", "예정" + MATCHING = "MATCHING", "팀매칭 중" + IN_PROJECT = "IN_PROJECT", "프로젝트 진행 중" + ENDED = "ENDED", "종료" + + name = models.CharField( + max_length=100, + help_text="시즌명 (예: 2026년 1월 시즌)", + ) + + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.UPCOMING, + ) + + # 팀매칭 기간 + matching_start = models.DateTimeField(help_text="팀매칭 시작") + matching_end = models.DateTimeField(help_text="팀매칭 종료") + + # 프로젝트 기간 + project_start = models.DateTimeField(help_text="프로젝트 시작") + project_end = models.DateTimeField(help_text="프로젝트 종료") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "seasons" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["status"]), + ] + + def __str__(self) -> str: + return f"{self.name} ({self.status})" + + def is_matching_period(self) -> bool: + """팀매칭 기간인지 확인""" + now = timezone.now() + return self.matching_start <= now <= self.matching_end + + def is_project_period(self) -> bool: + """프로젝트 기간인지 확인""" + now = timezone.now() + return self.project_start <= now <= self.project_end + + @classmethod + def get_active_season(cls): + """현재 활성화된 시즌 반환 (진행 중인 시즌)""" + return cls.objects.filter( + status__in=[cls.Status.MATCHING, cls.Status.IN_PROJECT] + ).order_by('-created_at').first() + + +class Project(models.Model): + """ + 프로젝트 + - 1 project = 1 team (1:1 관계) + - 팀 구성: PM 1명 / FE 2명 / BE 2명 (총 5명) + """ + + class Status(models.TextChoices): + DRAFT = "DRAFT", "임시저장" + OPEN = "OPEN", "모집중" + MATCHED = "MATCHED", "매칭완료" + IN_PROGRESS = "IN_PROGRESS", "진행중" + COMPLETED = "COMPLETED", "완료" + ARCHIVED = "ARCHIVED", "보관됨" + + season = models.ForeignKey( + Season, + on_delete=models.CASCADE, + related_name="projects", + null=True, + blank=True, + help_text="속한 시즌", + ) + + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="owned_projects", + help_text="프로젝트 생성자", + ) + + title = models.CharField( + max_length=120, + help_text="프로젝트 제목", + ) + + description = models.TextField( + null=True, + blank=True, + help_text="프로젝트 설명", + ) + + duration_weeks = models.SmallIntegerField( + default=6, + validators=[MinValueValidator(1)], + help_text="프로젝트 기간 (주)", + ) + + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.OPEN, + help_text="프로젝트 상태", + ) + + starts_at = models.DateField( + null=True, + blank=True, + help_text="시작 예정일", + ) + + ends_at = models.DateField( + null=True, + blank=True, + help_text="종료 예정일", + ) + + region = models.CharField( + max_length=80, + null=True, + blank=True, + help_text="활동 지역 (오프라인 시)", + ) + + # 대시보드에서 팀원이 수정 가능한 필드 + project_image = models.ImageField( + upload_to="projects/", + null=True, + blank=True, + help_text="프로젝트 프로필 사진", + ) + + team_rules = models.TextField( + null=True, + blank=True, + help_text="팀 규칙 (마크다운)", + ) + + related_links = models.TextField( + null=True, + blank=True, + help_text="관련 링크 (마크다운)", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "projects" + indexes = [ + models.Index(fields=["status"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self) -> str: + return self.title + + def get_like_count(self) -> int: + """좋아요 개수 반환""" + return self.likes.count() + + def is_liked_by(self, user) -> bool: + """특정 사용자가 좋아요를 눌렀는지 확인""" + if not user or user.is_anonymous: + return False + return self.likes.filter(user=user).exists() + + def toggle_like(self, user): + """사용자의 좋아요 상태 토글""" + like_obj, created = self.likes.get_or_create(user=user) + if not created: + like_obj.delete() + return created # True: 좋아요 추가, False: 좋아요 제거 + + +class ProjectLike(models.Model): + """ + 프로젝트 좋아요 + - 사용자가 프로젝트에 좋아요를 누를 수 있음 + - 중복 좋아요 방지 (User + Project 유니크) + """ + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="project_likes", + help_text="좋아요 누른 사용자", + ) + + project = models.ForeignKey( + 'Project', + on_delete=models.CASCADE, + related_name="likes", + help_text="좋아요 받은 프로젝트", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "project_likes" + unique_together = ("user", "project") + indexes = [ + models.Index(fields=["project"]), + models.Index(fields=["user"]), + ] + + def __str__(self) -> str: + return f"{self.user} ❤️ {self.project}" diff --git a/apps/projects/services.py b/apps/projects/services.py new file mode 100644 index 0000000..0c78779 --- /dev/null +++ b/apps/projects/services.py @@ -0,0 +1,488 @@ +""" +팀 매칭 알고리즘 및 관련 서비스 +""" +from collections import defaultdict +from itertools import product + +from django.db import transaction +from django.core.exceptions import ValidationError +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.conf import settings + +from apps.accounts.models import User, Role, UserRoleLevel +from apps.projects.models import Season, Project +from apps.teams.models import Team, TeamMember + + +class TeamMatchingService: + """ + 팀 매칭 알고리즘 서비스 + + 핵심 전략: + 1. preferred_role 기준으로 역할별 지원자 분류 + 2. 열정 레벨 인접 그룹(1-2, 2-3, 3-4)으로 묶기 + 3. 열정 그룹 내에서 실력 레벨 인접 그룹으로 세분화 + 4. 같은 열정 그룹 + 인접 실력 레벨 조합으로 팀 구성 + """ + + PM_PER_TEAM = 1 + FE_PER_TEAM = 2 + BE_PER_TEAM = 2 + TEAM_SIZE = PM_PER_TEAM + FE_PER_TEAM + BE_PER_TEAM + + # 인접 레벨 윈도우 (차이 ≤ 1) + ADJACENT_WINDOWS = [(1, 2), (2, 3), (3, 4)] + + # ────────────────────────────────────────── + # public API + # ────────────────────────────────────────── + @classmethod + def run_matching(cls, season_id): + """ + 팀 매칭 실행 (메인 엔트리포인트) + + Returns: + dict: 매칭 결과 통계 + """ + season = Season.objects.get(id=season_id) + + # ── 1. 지원자 수집 (한 번의 쿼리로 모든 정보 로드) ── + applicants = ( + User.objects + .filter( + passion_level__isnull=False, + preferred_role__isnull=False, + ) + .select_related('preferred_role') + .prefetch_related('role_levels__role') + ) + + if not applicants.exists(): + raise ValidationError("팀매칭 지원자가 없습니다.") + + # ── 2. preferred_role 기준 역할별 분류 ── + pool = cls._build_candidate_pool(applicants) + + # ── 3. 열정 인접 그룹별 → 실력 인접 그룹별 팀 구성 ── + team_slots = cls._assign_teams(pool) + + if not team_slots: + pm_n = len(pool.get('PM', [])) + fe_n = len(pool.get('FRONTEND', [])) + be_n = len(pool.get('BACKEND', [])) + raise ValidationError( + f"인접 레벨 조건을 만족하는 팀을 구성할 수 없습니다. " + f"(PM {pm_n}명, FE {fe_n}명, BE {be_n}명)" + ) + + # ── 4. DB에 팀 생성 (단일 트랜잭션) ── + result = cls._create_teams_in_db(season, team_slots) + return result + + # ────────────────────────────────────────── + # Step 2: 후보자 풀 구축 + # ────────────────────────────────────────── + @staticmethod + def _build_candidate_pool(applicants): + """ + 지원자를 역할별 dict[role_code → list[Candidate]] 로 변환. + Candidate = {user, level, passion} + """ + pool = defaultdict(list) + + for user in applicants: + role_code = user.preferred_role.code + + # prefetch된 role_levels에서 해당 역할의 레벨 조회 + level = 0 + for rl in user.role_levels.all(): + if rl.role.code == role_code: + level = rl.level + break + + if level == 0: + continue # 해당 역할 레벨이 없으면 제외 + + pool[role_code].append({ + 'user': user, + 'level': level, + 'passion': user.passion_level, + }) + + return dict(pool) + + # ────────────────────────────────────────── + # Step 3: 인접 레벨 기반 팀 배정 + # ────────────────────────────────────────── + @classmethod + def _assign_teams(cls, pool): + """ + 열정 인접 그룹 × 실력 인접 그룹 조합으로 팀 슬롯을 생성. + 각 팀 안에서 열정 차이 ≤ 1, 같은 역할 실력 차이 ≤ 1 보장. + + Returns: + list[dict]: [{'pm': [cand], 'fe': [cand, cand], 'be': [cand, cand]}, ...] + """ + pm_all = pool.get('PM', []) + fe_all = pool.get('FRONTEND', []) + be_all = pool.get('BACKEND', []) + + team_slots = [] + used_ids = set() # 이미 배정된 유저 id + + # 열정 윈도우별로 순회 (높은 열정 그룹부터) + for passion_lo, passion_hi in reversed(cls.ADJACENT_WINDOWS): + # 해당 열정 범위에 속하는 미배정 후보 필터 + pm_passion = cls._filter_unused(pm_all, used_ids, passion_lo, passion_hi) + fe_passion = cls._filter_unused(fe_all, used_ids, passion_lo, passion_hi) + be_passion = cls._filter_unused(be_all, used_ids, passion_lo, passion_hi) + + # 실력 윈도우별로 추가 세분화 + for level_lo, level_hi in reversed(cls.ADJACENT_WINDOWS): + pm_cands = [c for c in pm_passion if level_lo <= c['level'] <= level_hi] + fe_cands = [c for c in fe_passion if level_lo <= c['level'] <= level_hi] + be_cands = [c for c in be_passion if level_lo <= c['level'] <= level_hi] + + # 가능한 팀 수 계산 + n_teams = min( + len(pm_cands) // cls.PM_PER_TEAM, + len(fe_cands) // cls.FE_PER_TEAM, + len(be_cands) // cls.BE_PER_TEAM, + ) + + if n_teams == 0: + continue + + # 각 역할 내에서 레벨 편차를 최소화하도록 정렬 + pm_cands.sort(key=lambda c: c['level']) + fe_cands.sort(key=lambda c: c['level']) + be_cands.sort(key=lambda c: c['level']) + + pm_i = fe_i = be_i = 0 + for _ in range(n_teams): + slot = { + 'pm': pm_cands[pm_i:pm_i + cls.PM_PER_TEAM], + 'fe': fe_cands[fe_i:fe_i + cls.FE_PER_TEAM], + 'be': be_cands[be_i:be_i + cls.BE_PER_TEAM], + } + pm_i += cls.PM_PER_TEAM + fe_i += cls.FE_PER_TEAM + be_i += cls.BE_PER_TEAM + + # used_ids에 등록 + for cand in slot['pm'] + slot['fe'] + slot['be']: + used_ids.add(cand['user'].id) + + team_slots.append(slot) + + # passion 필터 목록도 갱신 (다음 level 윈도우에서 중복 방지) + pm_passion = [c for c in pm_passion if c['user'].id not in used_ids] + fe_passion = [c for c in fe_passion if c['user'].id not in used_ids] + be_passion = [c for c in be_passion if c['user'].id not in used_ids] + + # ── 2차: 남은 인원으로 완화 매칭 (열정 ≤ 1, 실력 제약 완화) ── + remaining_pm = cls._filter_unused(pm_all, used_ids) + remaining_fe = cls._filter_unused(fe_all, used_ids) + remaining_be = cls._filter_unused(be_all, used_ids) + + for passion_lo, passion_hi in reversed(cls.ADJACENT_WINDOWS): + pm_p = [c for c in remaining_pm if passion_lo <= c['passion'] <= passion_hi] + fe_p = [c for c in remaining_fe if passion_lo <= c['passion'] <= passion_hi] + be_p = [c for c in remaining_be if passion_lo <= c['passion'] <= passion_hi] + + n = min( + len(pm_p) // cls.PM_PER_TEAM, + len(fe_p) // cls.FE_PER_TEAM, + len(be_p) // cls.BE_PER_TEAM, + ) + if n == 0: + continue + + pm_p.sort(key=lambda c: c['level']) + fe_p.sort(key=lambda c: c['level']) + be_p.sort(key=lambda c: c['level']) + + pi = fi = bi = 0 + for _ in range(n): + slot = { + 'pm': pm_p[pi:pi + cls.PM_PER_TEAM], + 'fe': fe_p[fi:fi + cls.FE_PER_TEAM], + 'be': be_p[bi:bi + cls.BE_PER_TEAM], + } + pi += cls.PM_PER_TEAM + fi += cls.FE_PER_TEAM + bi += cls.BE_PER_TEAM + + for cand in slot['pm'] + slot['fe'] + slot['be']: + used_ids.add(cand['user'].id) + team_slots.append(slot) + + remaining_pm = [c for c in remaining_pm if c['user'].id not in used_ids] + remaining_fe = [c for c in remaining_fe if c['user'].id not in used_ids] + remaining_be = [c for c in remaining_be if c['user'].id not in used_ids] + + return team_slots + + # ────────────────────────────────────────── + # Step 4: DB 저장 + # ────────────────────────────────────────── + @classmethod + def _create_teams_in_db(cls, season, team_slots): + """트랜잭션 내에서 팀·프로젝트·멤버 일괄 생성""" + + # Role 객체 캐싱 (총 3회 쿼리 → 미리 1회) + roles = {r.code: r for r in Role.objects.all()} + + stats = {'pm': 0, 'fe': 0, 'be': 0} + + with transaction.atomic(): + teams_created = [] + + for idx, slot in enumerate(team_slots, 1): + project = Project.objects.create( + title=f"{season.name} 팀 {idx}", + description="자동 매칭된 팀 프로젝트", + season=season, + status='MATCHED', + ) + team = Team.objects.create( + project=project, + name=f"Team {idx}", + ) + + role_map = { + 'pm': ('PM', slot['pm']), + 'fe': ('FRONTEND', slot['fe']), + 'be': ('BACKEND', slot['be']), + } + for key, (role_code, members) in role_map.items(): + for cand in members: + TeamMember.objects.create( + team=team, user=cand['user'], role=roles[role_code], + ) + stats[key] += 1 + + teams_created.append(team) + + total = stats['pm'] + stats['fe'] + stats['be'] + return { + 'teams_created': len(teams_created), + 'total_users_matched': total, + 'pm_matched': stats['pm'], + 'fe_matched': stats['fe'], + 'be_matched': stats['be'], + } + + # ────────────────────────────────────────── + # 헬퍼 + # ────────────────────────────────────────── + @staticmethod + def _filter_unused(candidates, used_ids, passion_lo=None, passion_hi=None): + """미배정 후보 중 열정 범위에 해당하는 후보만 반환""" + result = [c for c in candidates if c['user'].id not in used_ids] + if passion_lo is not None and passion_hi is not None: + result = [c for c in result if passion_lo <= c['passion'] <= passion_hi] + return result + + +class EmailService: + """이메일 발송 서비스""" + + @staticmethod + def send_matching_start_notification(season_id): + """ + 팀 매칭 기간 시작 알림 이메일 발송 + - 모든 사용자에게 매칭 신청 유도 이메일 발송 + + Args: + season_id: Season ID + + Returns: + dict: 발송 결과 통계 + """ + season = Season.objects.get(id=season_id) + + # 이메일 알림 활성화 사용자 조회 + users = User.objects.filter( + email_notifications_enabled=True + ).exclude(email='') + + sent_count = 0 + failed_count = 0 + + for user in users: + try: + EmailService._send_matching_start_email( + user=user, + season=season + ) + sent_count += 1 + except Exception as e: + print(f"❌ 사용자 {user.id} ({user.email}) 이메일 발송 실패: {str(e)}") + failed_count += 1 + + return { + 'season_id': season_id, + 'users_total': users.count(), + 'sent_count': sent_count, + 'failed_count': failed_count, + } + + @staticmethod + def _send_matching_start_email(user, season): + """ + 개별 사용자에게 팀 매칭 기간 시작 알림 발송 + + Args: + user: 수신자 + season: 현재 시즌 + """ + context = { + 'user': user, + 'season': season, + 'matching_start': season.matching_start.strftime('%Y년 %m월 %d일'), + 'matching_end': season.matching_end.strftime('%Y년 %m월 %d일'), + } + + # HTML 템플릿 렌더링 + html_message = render_to_string( + 'emails/matching_start.html', + context + ) + + # 일반 텍스트 버전 + text_message = render_to_string( + 'emails/matching_start.txt', + context + ) + + # 이메일 발송 + send_mail( + subject=f'[KITUP] {season.name} 팀 매칭이 시작되었습니다', + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + @staticmethod + def send_matching_results(season_id): + """ + 팀 매칭 결과 이메일 발송 + - 매칭된 팀의 멤버들에게만 발송 + - 발송 완료 후 email_notifications_enabled = False로 변경 + + N+1 쿼리 최적화: prefetch_related 사용 + + Args: + season_id: Season ID + + Returns: + dict: 발송 결과 통계 + """ + season = Season.objects.get(id=season_id) + + # 현재 시즌의 모든 팀 조회 + # N+1 쿼리 최적화: members__user__role_levels를 미리 로드 + teams = Team.objects.filter( + project__season=season + ).prefetch_related( + 'members__user__role_levels', + 'members__role', + 'project' + ).distinct() + + sent_count = 0 + failed_count = 0 + notified_users = [] + + for team in teams: + try: + # 각 팀의 모든 멤버에게 매칭 결과 이메일 발송 + for member in team.members.all(): + if member.user.email_notifications_enabled: + EmailService._send_team_matching_email( + user=member.user, + team=team, + season=season + ) + notified_users.append(member.user.id) + sent_count += 1 + except Exception as e: + print(f"❌ 팀 {team.id} 이메일 발송 실패: {str(e)}") + failed_count += 1 + + # 이메일을 받은 사용자들의 email_notifications_enabled를 False로 변경 + if notified_users: + User.objects.filter(id__in=notified_users).update(email_notifications_enabled=False) + + return { + 'season_id': season_id, + 'teams_total': teams.count(), + 'sent_count': sent_count, + 'failed_count': failed_count, + 'notified_users': len(notified_users), + } + + @staticmethod + def _send_team_matching_email(user, team, season): + """ + 개별 사용자에게 팀 매칭 결과 이메일 발송 + + N+1 쿼리 최적화: prefetch_related된 데이터 사용 + + Args: + user: 수신자 + team: 할당된 팀 + season: 현재 시즌 + """ + # 팀원 정보 수집 + # prefetch_related된 데이터만 사용 (DB 쿼리 없음) + team_members = [] + for member in team.members.all(): + # prefetch_related된 role_levels에서 빠르게 조회 + role_level = None + for rl in member.user.role_levels.all(): + if rl.role.code == member.role.code: + role_level = rl + break + + team_members.append({ + 'user': member.user, + 'role': member.role, + 'level': role_level.level if role_level else None, + }) + + # 이메일 컨텍스트 + context = { + 'user': user, + 'team': team, + 'team_members': team_members, + 'season': season, + 'project_start': season.project_start.strftime('%Y년 %m월 %d일'), + 'project_end': season.project_end.strftime('%Y년 %m월 %d일'), + } + + # HTML 템플릿 렌더링 + html_message = render_to_string( + 'emails/matching_result.html', + context + ) + + # 일반 텍스트 버전 + text_message = render_to_string( + 'emails/matching_result.txt', + context + ) + + # 이메일 발송 + send_mail( + subject=f'[KITUP] {season.name} 팀 매칭 완료', + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) diff --git a/apps/projects/templatetags/__init__.py b/apps/projects/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/templatetags/markdown_tags.py b/apps/projects/templatetags/markdown_tags.py new file mode 100644 index 0000000..7d4bdaf --- /dev/null +++ b/apps/projects/templatetags/markdown_tags.py @@ -0,0 +1,12 @@ +from django import template +import markdown as md + +register = template.Library() + + +@register.filter +def markdown(text): + """마크다운을 HTML로 변환""" + if not text: + return "" + return md.markdown(text) diff --git a/apps/projects/tests.py b/apps/projects/tests.py new file mode 100644 index 0000000..d523511 --- /dev/null +++ b/apps/projects/tests.py @@ -0,0 +1,326 @@ +""" +팀 매칭 알고리즘 테스트 +""" +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from datetime import timedelta +from django.core.exceptions import ValidationError + +from apps.projects.models import Season, Project +from apps.projects.services import TeamMatchingService +from apps.accounts.models import User, Role, UserRoleLevel +from apps.teams.models import Team, TeamMember + + +class TeamMatchingServiceTest(TestCase): + """팀 매칭 서비스 테스트""" + + def setUp(self): + """테스트 데이터 준비""" + # Role 생성 + self.pm_role = Role.objects.create(code='PM', name='기획') + self.fe_role = Role.objects.create(code='FRONTEND', name='프론트엔드') + self.be_role = Role.objects.create(code='BACKEND', name='백엔드') + + # Season 생성 (진행 중) + now = timezone.now() + self.season = Season.objects.create( + name="테스트 시즌", + status="MATCHING", + is_active=True, + matching_start=now - timedelta(days=1), + matching_end=now + timedelta(days=1), + project_start=now + timedelta(days=2), + project_end=now + timedelta(days=30), + ) + + def _create_user(self, username, role, level, passion_level=3): + """테스트 사용자 생성""" + user = User.objects.create_user( + username=username, + email=f'{username}@test.com', + password='test1234', + nickname=username + ) + user.passion_level = passion_level + user.save() + UserRoleLevel.objects.create(user=user, role=role, level=level) + return user + + def test_matching_success_exact_count(self): + """✅ 정확한 인원 매칭""" + # PM 2명, FE 4명, BE 4명 생성 (팀 2개) + for i in range(2): + self._create_user(f'pm_{i}', self.pm_role, 3) + for i in range(4): + self._create_user(f'fe_{i}', self.fe_role, 2) + for i in range(4): + self._create_user(f'be_{i}', self.be_role, 2) + + result = TeamMatchingService.run_matching(self.season.id) + + self.assertEqual(result['teams_created'], 2) + self.assertEqual(result['total_users_matched'], 10) + self.assertEqual(result['pm_matched'], 2) + self.assertEqual(result['fe_matched'], 4) + self.assertEqual(result['be_matched'], 4) + self.assertEqual(result['total_unmatched'], 0) + + def test_matching_partial_unmatched(self): + """✅ 일부 인원 매칭 실패""" + # PM 3명, FE 5명, BE 5명 생성 (팀 2개 + 1명씩 남음) + for i in range(3): + self._create_user(f'pm_{i}', self.pm_role, 3) + for i in range(5): + self._create_user(f'fe_{i}', self.fe_role, 2) + for i in range(5): + self._create_user(f'be_{i}', self.be_role, 2) + + result = TeamMatchingService.run_matching(self.season.id) + + self.assertEqual(result['teams_created'], 2) + self.assertEqual(result['total_users_matched'], 10) + self.assertEqual(result['total_unmatched'], 3) + self.assertEqual(result['unmatched']['pm'], 1) + self.assertEqual(result['unmatched']['fe'], 1) + self.assertEqual(result['unmatched']['be'], 1) + + def test_matching_insufficient_pm(self): + """❌ PM 부족 (매칭 불가)""" + # PM 0명, FE 5명, BE 5명 + for i in range(5): + self._create_user(f'fe_{i}', self.fe_role, 2) + for i in range(5): + self._create_user(f'be_{i}', self.be_role, 2) + + with self.assertRaises(ValidationError) as context: + TeamMatchingService.run_matching(self.season.id) + + self.assertIn('팀을 만들 수 없습니다', str(context.exception)) + + def test_matching_no_applicants(self): + """❌ 지원자 없음""" + with self.assertRaises(ValidationError) as context: + TeamMatchingService.run_matching(self.season.id) + + self.assertIn('지원자가 없습니다', str(context.exception)) + + def test_matching_level_sorting(self): + """✅ 역할 레벨 기준 정렬 확인""" + # PM 2명 (레벨 1, 3) + pm1 = self._create_user('pm_low', self.pm_role, 1) + pm2 = self._create_user('pm_high', self.pm_role, 3) + + # FE 4명 + for i in range(4): + self._create_user(f'fe_{i}', self.fe_role, 2) + + # BE 4명 + for i in range(4): + self._create_user(f'be_{i}', self.be_role, 2) + + result = TeamMatchingService.run_matching(self.season.id) + + # 팀 생성 확인 + teams = Team.objects.all() + self.assertEqual(teams.count(), 2) + + # 첫 번째 팀에 높은 레벨 PM이 배정되는지 확인 + first_team = teams.first() + pm_member = first_team.members.filter(role=self.pm_role).first() + self.assertEqual(pm_member.user.nickname, 'pm_high') + + def test_team_composition(self): + """✅ 팀 구성 검증 (PM1, FE2, BE2)""" + # PM 1명, FE 2명, BE 2명 + self._create_user('pm_1', self.pm_role, 2) + self._create_user('fe_1', self.fe_role, 2) + self._create_user('fe_2', self.fe_role, 2) + self._create_user('be_1', self.be_role, 2) + self._create_user('be_2', self.be_role, 2) + + result = TeamMatchingService.run_matching(self.season.id) + + team = Team.objects.first() + members = team.members.all() + + pm_count = members.filter(role=self.pm_role).count() + fe_count = members.filter(role=self.fe_role).count() + be_count = members.filter(role=self.be_role).count() + + self.assertEqual(pm_count, 1) + self.assertEqual(fe_count, 2) + self.assertEqual(be_count, 2) + + +class ProjectDashboardViewTest(TestCase): + """프로젝트 대시보드 뷰 테스트""" + + def setUp(self): + """테스트 데이터 준비""" + # 시즌 생성 + self.season = Season.objects.create( + name="2026년 1월 시즌", + status=Season.Status.IN_PROJECT, + matching_start=timezone.now() - timedelta(days=10), + matching_end=timezone.now() - timedelta(days=5), + project_start=timezone.now() - timedelta(days=3), + project_end=timezone.now() + timedelta(days=30), + is_active=True, + ) + + # 역할 생성 + self.pm_role = Role.objects.create(code="PM", name="프로덕트 매니저") + self.fe_role = Role.objects.create(code="FE", name="프론트엔드") + self.be_role = Role.objects.create(code="BE", name="백엔드") + + # 사용자 생성 후 프로필 완성 + self.pm_user = User.objects.create_user( + username="pm_user", + email="pm@test.com", + password="testpass123", + ) + self.pm_user.nickname = "PM 유저" + self.pm_user.save() + + self.fe_user = User.objects.create_user( + username="fe_user", + email="fe@test.com", + password="testpass123", + ) + self.fe_user.nickname = "FE 유저" + self.fe_user.save() + + # 프로젝트 생성 + self.project = Project.objects.create( + title="테스트 프로젝트", + description="테스트 설명", + status=Project.Status.IN_PROGRESS, + ) + + # 팀 생성 + self.team = Team.objects.create( + project=self.project, + name="테스트 팀", + ) + + # 팀 멤버 추가 + self.pm_member = TeamMember.objects.create( + team=self.team, + user=self.pm_user, + role=self.pm_role, + is_active=True, + ) + self.fe_member = TeamMember.objects.create( + team=self.team, + user=self.fe_user, + role=self.fe_role, + is_active=True, + ) + + def test_dashboard_no_project(self): + """진행 중인 프로젝트 없을 때""" + user = User.objects.create_user( + username="no_project", + email="no@test.com", + password="testpass123", + ) + user.nickname = "No Project User" + user.save() + + self.client.login(username="no_project", password="testpass123") + + response = self.client.get(reverse("projects:dashboard")) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.context["has_project"]) + + def test_dashboard_with_project(self): + """진행 중인 프로젝트 있을 때""" + self.client.login(username="pm_user", password="testpass123") + + response = self.client.get(reverse("projects:dashboard")) + # 프로젝트 있으면 redirect + self.assertEqual(response.status_code, 302) + + def test_dashboard_detail_access(self): + """대시보드 상세 조회""" + self.client.login(username="pm_user", password="testpass123") + + url = reverse("projects:dashboard_detail", kwargs={"project_id": self.project.id}) + print(f"\n🔍 Testing URL: {url}") + print(f" Project ID: {self.project.id}") + print(f" User: pm_user") + print(f" TeamMember exists: {TeamMember.objects.filter(user=self.pm_user, is_active=True).exists()}") + + response = self.client.get(url) + + print(f" Response status: {response.status_code}") + if response.status_code == 302: + print(f" Redirected to: {response.url}") + + self.assertEqual(response.status_code, 200) + + def test_dashboard_detail_not_member(self): + """대시보드 상세 - 팀원 아닐 때""" + user = User.objects.create_user( + username="non_member", + email="non@test.com", + password="testpass123", + ) + self.client.login(username="non_member", password="testpass123") + + response = self.client.get( + reverse("projects:dashboard_detail", kwargs={"project_id": self.project.id}) + ) + self.assertEqual(response.status_code, 302) # redirect + + def test_project_list_access(self): + """과거 프로젝트 리스트""" + self.client.login(username="pm_user", password="testpass123") + + response = self.client.get(reverse("projects:project_list")) + self.assertEqual(response.status_code, 200) + + def test_project_detail_access(self): + """과거 프로젝트 상세 조회""" + self.client.login(username="pm_user", password="testpass123") + + response = self.client.get( + reverse("projects:project_detail", kwargs={"project_id": self.project.id}) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["project"], self.project) + + def test_dashboard_update_get(self): + """대시보드 수정 폼 조회""" + self.client.login(username="pm_user", password="testpass123") + + response = self.client.get( + reverse("projects:dashboard_edit", kwargs={"project_id": self.project.id}) + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context) + self.assertIn("links_form", response.context) + + def test_dashboard_update_post(self): + """대시보드 정보 수정""" + self.client.login(username="pm_user", password="testpass123") + + data = { + "title": "수정된 프로젝트명", + "description": "수정된 설명", + "is_favorite": True, + } + response = self.client.post( + reverse("projects:dashboard_edit", kwargs={"project_id": self.project.id}), + data, + ) + + # 수정 후 redirect + self.assertEqual(response.status_code, 302) + + # 데이터 확인 + self.project.refresh_from_db() + self.assertEqual(self.project.title, "수정된 프로젝트명") + self.assertTrue(self.project.is_favorite) diff --git a/apps/projects/urls.py b/apps/projects/urls.py new file mode 100644 index 0000000..43cf0f5 --- /dev/null +++ b/apps/projects/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from . import views + +app_name = "projects" + +urlpatterns = [ + # 프로젝트 대시보드 (현재 프로젝트) + path("dashboard/", views.dashboard, name="dashboard"), # dashboard.html + path("dashboard//", views.dashboard_detail, name="dashboard_detail"), # 대시보드 조회 + path("dashboard//edit/", views.dashboard_update, name="dashboard_edit"), # 대시보드 수정 + + # 지난 프로젝트 + path("", views.project_list, name="project_list"), # project_list.html + path("/", views.project_detail, name="project_detail"), # project_detail.html + + # KITUP 프로젝트 (모든 프로젝트) + path("all/", views.kitup_list, name="kitup_list"), # kitup_list.html + path("all//", views.kitup_detail, name="kitup_detail"), # kitup_detail.html + path("all//like/", views.toggle_project_like, name="toggle_project_like"), # 좋아요 API + + # 팀 매칭 관리 + path("matching//run/", views.run_team_matching, name="run_team_matching"), # API +] diff --git a/apps/projects/views.py b/apps/projects/views.py new file mode 100644 index 0000000..2646fac --- /dev/null +++ b/apps/projects/views.py @@ -0,0 +1,366 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required, permission_required +from django.contrib import messages +from django.http import JsonResponse +from django.core.exceptions import ValidationError +from django.views.decorators.http import require_POST, require_http_methods + +from apps.projects.models import Season, Project +from apps.projects.forms import ProjectDashboardEditForm +from apps.projects.services import TeamMatchingService +from apps.teams.models import Team, TeamMember + + +# ================================ +# Helper 함수 +# ================================ + +def _get_project_context(project, user): + """ + 프로젝트 상세 정보 context 생성 (dashboard_detail, project_detail에서 공유) + N+1 쿼리 최적화: prefetch_related 및 캐싱 활용 + """ + team = project.team + # N+1 쿼리 최적화: user__role_levels를 미리 로드 + members = team.members.filter(is_active=True).select_related( + "user", + "role" + ).prefetch_related("user__role_levels") + member_count_by_role = team.get_member_count_by_role() + + # 각 멤버에 레벨 정보 추가 (캐시된 role_levels 사용) + members_with_level = [] + for member in members: + # prefetch_related된 role_levels에서 직접 조회 (DB 쿼리 없음) + role_level = None + for rl in member.user.role_levels.all(): + if rl.role.code == member.role.code: + role_level = rl.level + break + + member_data = { + 'member': member, + 'level': role_level or 0 + } + members_with_level.append(member_data) + + + # 시즌 정보 + season = None + active_season = Season.get_active_season() + if active_season: + if active_season.project_start <= project.created_at <= active_season.project_end: + season = active_season + + # 가이드 진척도 계산 (전체 미션 합산) + guide_progress = None + try: + from apps.guides.models import GuideCard, GuideTaskProgress + from apps.accounts.models import Role + + # 프로젝트의 모든 팀원 역할 가져오기 + # N+1 쿼리 최적화: values_list + in 사용하여 role ID만 먼저 추출 + team_role_ids = team.members.filter(is_active=True).values_list( + 'role_id', flat=True + ).distinct() + + # 한 번에 모든 카드와 태스크 로드 + cards_with_tasks = GuideCard.objects.filter( + role_id__in=team_role_ids, + is_active=True + ).prefetch_related('tasks') # 카드와 태스크를 한 번에 로드 + + total_tasks = 0 + completed_tasks = 0 + + # 메모리에서만 작업 (DB 쿼리 없음) + for card in cards_with_tasks: + for task in card.tasks.all(): # prefetch_related로 이미 로드됨 + total_tasks += 1 + # 이 태스크가 프로젝트에서 완료되었는지 확인 + is_completed = GuideTaskProgress.objects.filter( + task=task, + project=project, + is_completed=True + ).exists() + + if is_completed: + completed_tasks += 1 + + progress_percent = int((completed_tasks / total_tasks * 100) if total_tasks > 0 else 0) + + guide_progress = { + 'total_tasks': total_tasks, + 'completed_tasks': completed_tasks, + 'progress_percent': progress_percent, + } + except: + # GuideCard 모델이 없거나 데이터가 없으면 None으로 처리 + guide_progress = None + + return { + "project": project, + "team": team, + "members": members, + "members_with_level": members_with_level, + "member_count_by_role": member_count_by_role, + "season": season, + "guide_progress": guide_progress, + } + + +@login_required +def dashboard(request): + """ + 현재 프로젝트 대시보드 진입점 + + - 사용자가 속한 현재 진행 중인 프로젝트가 있으면 해당 프로젝트 대시보드로 리다이렉트 + - 없으면 "현재 진행중인 프로젝트가 없어요" 페이지 렌더링 + """ + + # 사용자의 현재 진행 중인 프로젝트 찾기 + team_member = TeamMember.objects.filter( + user=request.user, + is_active=True + ).select_related('team__project').first() + + # 프로젝트 있으면 상세 페이지로 리다이렉트 + if team_member and team_member.team.project: + return redirect('projects:dashboard_detail', project_id=team_member.team.project.id) + + # 프로젝트 없으면 "현재 진행중인 프로젝트가 없어요" 페이지 표시 + return render(request, "projects/dashboard.html", {"has_project": False}) + + +@login_required +@require_http_methods(["GET"]) +def dashboard_detail(request, project_id): + """프로젝트 대시보드 조회 (진행 중인 프로젝트)""" + project = get_object_or_404(Project, id=project_id) + + # 팀원 확인 + is_team_member = TeamMember.objects.filter( + team__project=project, + user=request.user, + is_active=True + ).exists() + + if not is_team_member: + messages.error(request, "팀원만 접근할 수 있습니다.") + return redirect("projects:dashboard") + + context = _get_project_context(project, request.user) + context["is_team_member"] = is_team_member + + return render(request, "projects/dashboard.html", context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def dashboard_update(request, project_id): + """프로젝트 대시보드 조회 (진행 중인 프로젝트)""" + project = get_object_or_404(Project, id=project_id) + + """ + 프로젝트 대시보드 수정 (팀원만) + + 수정 가능 필드: + - 서비스명 (title) + - 서비스 소개 (description) + - 프로필 사진 (project_image) + - 팀 규칙 (team_rules) + - 관련 링크 (related_links) + - 즐겨찾기 (is_favorite) + """ + + # 팀원 권한 확인 + is_team_member = TeamMember.objects.filter( + team__project=project, + user=request.user, + is_active=True + ).exists() + + if not is_team_member: + messages.error(request, "팀원만 수정할 수 있습니다.") + return redirect("projects:dashboard_detail", project_id=project_id) + + if request.method == "POST": + form = ProjectDashboardEditForm(request.POST, request.FILES, instance=project) + + if form.is_valid(): + form.save() + + messages.success(request, "✅ 프로젝트 정보가 수정되었습니다.") + return redirect("projects:dashboard_detail", project_id=project_id) + else: + messages.error(request, "❌ 입력 오류가 있습니다. 다시 확인해주세요.") + else: + form = ProjectDashboardEditForm(instance=project) + + context = _get_project_context(project, request.user) + context["form"] = form + context["is_team_member"] = is_team_member + + return render(request, "projects/dashboard_update.html", context) + + +@login_required +@require_http_methods(["GET"]) +def project_list(request): + """과거 프로젝트 리스트 (완료된 프로젝트)""" + # 사용자가 속했던 모든 팀의 프로젝트 + projects = Project.objects.filter( + team__members__user=request.user, + team__members__is_active=False # 비활성 (완료된 팀) + ).distinct().select_related('team').order_by('-created_at') + + context = { + "projects": projects, + } + return render(request, "projects/project_list.html", context) + + +@login_required +@require_http_methods(["GET"]) +def project_detail(request, project_id): + """과거 프로젝트 상세 (조회만)""" + project = get_object_or_404(Project, id=project_id) + + # 사용자가 해당 프로젝트에 속했었는지 확인 + is_member = TeamMember.objects.filter( + team__project=project, + user=request.user + ).exists() + + if not is_member: + messages.error(request, "접근 권한이 없습니다.") + return redirect("projects:project_list") + + context = _get_project_context(project, request.user) + + return render(request, "projects/project_detail.html", context) + + +@login_required +@require_http_methods(["GET"]) +def kitup_list(request): + """모든 KITUP 프로젝트 리스트 (완료된 보관 프로젝트)""" + # 보관된 프로젝트만 조회 (ARCHIVED 상태) + projects = Project.objects.filter( + status=Project.Status.ARCHIVED + ).select_related('team') + + # 정렬 처리 + sort = request.GET.get('sort', 'popular') + if sort == 'latest': + projects = projects.order_by('-created_at') + elif sort == 'oldest': + projects = projects.order_by('created_at') + # 내 프로젝트 필터링 옵션 + elif sort == 'my_projects': + projects = projects.filter( + team__members__user=request.user + ).distinct().order_by('-created_at') + + else: # popular (기본값) + # annotate로 좋아요 개수 추가하여 정렬 + from django.db.models import Count + projects = projects.annotate(like_count=Count('likes')).order_by('-like_count', '-created_at') + + context = { + "projects": projects, + "sort": sort, + } + return render(request, "projects/kitup_list.html", context) + + +@login_required +@require_http_methods(["GET"]) +def kitup_detail(request, project_id): + """모든 KITUP 프로젝트 상세 (보관된 프로젝트 조회만)""" + project = get_object_or_404(Project, id=project_id, status=Project.Status.ARCHIVED) + + context = _get_project_context(project, request.user) + + return render(request, "projects/kitup_detail.html", context) + + +@login_required +@require_POST +@login_required +@require_POST +def toggle_project_like(request, project_id): + """프로젝트 좋아요 토글 API""" + from django.http import JsonResponse + + project = get_object_or_404(Project, id=project_id) + + # 좋아요 토글 + is_liked = project.toggle_like(request.user) + like_count = project.get_like_count() + + return JsonResponse({ + 'success': True, + 'is_liked': is_liked, + 'like_count': like_count, + }) + + +# ================================ +# 팀 매칭 관리 API +# ================================ + +@permission_required('projects.add_project', raise_exception=True) +@require_POST +def run_team_matching(request, season_id): + """ + 팀 매칭 알고리즘 실행 (관리자만) + + POST /projects/matching/{season_id}/run/ + + Returns: + JSON: 매칭 결과 통계 + """ + try: + season = Season.objects.get(id=season_id) + except Season.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': f'시즌 ID {season_id}를 찾을 수 없습니다.', + }, status=404) + + # 팀매칭 기간 확인 + if not season.is_matching_period(): + return JsonResponse({ + 'success': False, + 'error': '현재 팀매칭 기간이 아닙니다.', + }, status=400) + + try: + result = TeamMatchingService.run_matching(season_id) + + return JsonResponse({ + 'success': True, + 'message': f'✅ 팀 매칭 완료! {result["teams_created"]}개 팀 생성', + 'data': { + 'teams_created': result['teams_created'], + 'total_users_matched': result['total_users_matched'], + 'pm_matched': result['pm_matched'], + 'fe_matched': result['fe_matched'], + 'be_matched': result['be_matched'], + 'total_unmatched': result['total_unmatched'], + 'unmatched_details': result['unmatched'], + } + }) + + except ValidationError as e: + return JsonResponse({ + 'success': False, + 'error': f'❌ 매칭 실패: {str(e.message)}', + }, status=400) + + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': f'❌ 예상치 못한 오류: {str(e)}', + }, status=500) diff --git a/apps/reflections/__init__.py b/apps/reflections/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reflections/admin.py b/apps/reflections/admin.py new file mode 100644 index 0000000..d25c358 --- /dev/null +++ b/apps/reflections/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import Retrospective + + +@admin.register(Retrospective) +class RetrospectiveAdmin(admin.ModelAdmin): + list_display = ["id", "project", "user", "title", "created_at", "updated_at"] + list_filter = ["created_at", "project"] + search_fields = ["title", "user__nickname", "project__title"] + ordering = ["-created_at"] diff --git a/apps/reflections/api_urls.py b/apps/reflections/api_urls.py new file mode 100644 index 0000000..4527a27 --- /dev/null +++ b/apps/reflections/api_urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from .views import RetrospectiveViewSet + +router = DefaultRouter() +router.register("retrospectives",RetrospectiveViewSet, basename="retrospective") + +urlpatterns = router.urls \ No newline at end of file diff --git a/apps/reflections/apps.py b/apps/reflections/apps.py new file mode 100644 index 0000000..86ab761 --- /dev/null +++ b/apps/reflections/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class ReflectionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reflections" + label = "reflections" + + def ready(self): + from . import signals # noqa \ No newline at end of file diff --git a/apps/reflections/guide_templates/retrospective_compact.json b/apps/reflections/guide_templates/retrospective_compact.json new file mode 100644 index 0000000..17968b0 --- /dev/null +++ b/apps/reflections/guide_templates/retrospective_compact.json @@ -0,0 +1,10 @@ +{ + "key": "compact", + "title": "오늘의 회고", + "intro": [], + "questions": [ + { "id": "q1_study", "order": 1, "title": "오늘 무엇을 공부했나요?" }, + { "id": "q2_hard", "order": 2, "title": "어려웠던 지점은 무엇이었고, 어떻게 해결하거나 해결하지 못했나요?" }, + { "id": "q3_tomorrow", "order": 3, "title": "내일은 무엇을 할 것인가요?" } + ] +} diff --git a/apps/reflections/guide_templates/retrospective_default.json b/apps/reflections/guide_templates/retrospective_default.json new file mode 100644 index 0000000..baaeecc --- /dev/null +++ b/apps/reflections/guide_templates/retrospective_default.json @@ -0,0 +1,90 @@ +{ + "key": "default", + "title": "오늘의 회고 작성 가이드", + "intro": [ + "아래 질문에 따라 오늘의 작업을 정리해 주세요.", + "단순한 감정 기록이 아니라, 업무 내용과 판단 과정이 남는 회고를 목표로 합니다." + ], + "questions": [ + { + "id": "q1_work_done", + "order": 1, + "title": "오늘 내가 맡아서 수행한 업무는 무엇이었나요?", + "hint": "오늘 직접 담당하여 진행한 작업을 구체적으로 적어주세요.", + "examples": [ + "user 모델 설계 및 마이그레이션", + "main.html 레이아웃 구현", + "레벨 진단 설문 로직 구현" + ] + }, + { + "id": "q2_why_design", + "order": 2, + "title": "이 업무를 왜 이렇게 설계하거나 구현했나요?", + "hint": "해당 작업에서 어떤 선택을 했고, 그 이유는 무엇이었는지 적어주세요.", + "examples": [ + "추후 역할별 레벨 확장을 고려해 테이블을 분리했습니다.", + "프론트와 데이터 연결을 단순화하기 위해 URL 구조를 정리했습니다." + ] + }, + { + "id": "q3_blockers", + "order": 3, + "title": "진행 중에 어려웠던 점이나 막혔던 부분은 무엇이었나요?", + "hint": "작업하면서 마주한 문제, 혼란스러웠던 점을 적어주세요.", + "examples": [ + "allauth와 커스텀 유저 모델 충돌 문제", + "프론트에서 필요한 데이터 구조와 백엔드 설계 간의 차이" + ] + }, + { + "id": "q4_resolution", + "order": 4, + "title": "그 문제를 어떻게 해결했나요? (또는 왜 아직 해결하지 못했나요?)", + "hint": "문제 해결 과정이나, 해결하지 못했다면 그 이유를 적어주세요.", + "examples": [ + "공식 문서를 참고해 settings 구조를 수정했습니다.", + "시간 부족으로 임시 처리 후 이슈로 남겼습니다." + ] + }, + { + "id": "q5_sync", + "order": 5, + "title": "오늘 팀원과 공유하거나 조정한 내용은 무엇이었나요?", + "hint": "협업 과정에서 의사소통하거나 합의한 내용을 적어주세요.", + "examples": [ + "프론트와 API 응답 구조에 대해 논의했습니다.", + "PM과 기능 범위 조정을 진행했습니다." + ] + }, + { + "id": "q6_learnings", + "order": 6, + "title": "오늘 작업을 통해 새롭게 알게 된 점이나 배운 점은 무엇인가요?", + "hint": "기술, 협업 방식, 문제 해결 측면에서의 배움을 적어주세요.", + "examples": [ + "Docker 멀티 컨테이너 구조에 대한 이해가 깊어졌습니다.", + "API 설계 시 일관성이 중요하다는 것을 느꼈습니다." + ] + }, + { + "id": "q7_next", + "order": 7, + "title": "다음 작업에서 개선하거나 달리 해보고 싶은 점은 무엇인가요?", + "hint": "다음 작업을 위해 개선하고 싶은 부분이나 계획을 적어주세요.", + "examples": [ + "API 명세를 더 일찍 공유하고 싶습니다.", + "마이그레이션 파일 관리를 더 체계적으로 하고 싶습니다." + ] + }, + { + "id": "q8_one_liner", + "order": 8, + "title": "오늘 작업의 결과를 한 문장으로 요약한다면?", + "hint": "오늘의 작업을 핵심 한 문장으로 정리해 주세요.", + "examples": [ + "팀 매칭 신청 플로우의 백엔드 구조를 완성했습니다." + ] + } + ] +} diff --git a/apps/reflections/management/commands/cleanup_temp__assets.py b/apps/reflections/management/commands/cleanup_temp__assets.py new file mode 100644 index 0000000..bef1a8a --- /dev/null +++ b/apps/reflections/management/commands/cleanup_temp__assets.py @@ -0,0 +1,47 @@ +# apps/reflections/management/commands/cleanup_temp_assets.py +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.conf import settings + +from apps.reflections.models import RetrospectiveAsset + + +class Command(BaseCommand): + help = "Delete temporary retrospective assets (retrospective is NULL) older than TTL." + + def add_arguments(self, parser): + parser.add_argument( + "--hours", + type=int, + default=24, + help="TTL in hours (default: 24). Assets older than this will be deleted.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be deleted without actually deleting.", + ) + + def handle(self, *args, **options): + hours = options["hours"] + dry_run = options["dry_run"] + + cutoff = timezone.now() - timedelta(hours=hours) + + qs = RetrospectiveAsset.objects.filter( + retrospective__isnull=True, + created_at__lt=cutoff, + ) + + count = qs.count() + + if dry_run: + self.stdout.write(self.style.WARNING(f"[DRY RUN] would delete {count} temp assets (hours={hours})")) + return + + # ✅ delete()는 post_delete 시그널이 있으면 파일도 삭제됩니다. + deleted = qs.delete() + # deleted는 (총 삭제 수, {모델: 수}) 형태 + self.stdout.write(self.style.SUCCESS(f"deleted {count} temp assets (hours={hours})")) diff --git a/apps/reflections/migrations/0001_initial.py b/apps/reflections/migrations/0001_initial.py new file mode 100644 index 0000000..7267f49 --- /dev/null +++ b/apps/reflections/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.10 on 2026-01-30 05:10 + +import django.db.models.deletion +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='Retrospective', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, help_text='회고 제목', max_length=120, null=True)), + ('content_md', models.TextField(help_text='회고 내용 (마크다운)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='retrospectives', to='projects.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='retrospectives', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'retrospectives', + 'indexes': [models.Index(fields=['project', 'user'], name='retrospecti_project_1604fb_idx'), models.Index(fields=['created_at'], name='retrospecti_created_521842_idx')], + }, + ), + ] diff --git a/apps/reflections/migrations/0002_retrospective_bookmarked.py b/apps/reflections/migrations/0002_retrospective_bookmarked.py new file mode 100644 index 0000000..0034240 --- /dev/null +++ b/apps/reflections/migrations/0002_retrospective_bookmarked.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.10 on 2026-02-04 14:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reflections", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="retrospective", + name="bookmarked", + field=models.BooleanField(default=False, help_text="찜 여부"), + ), + ] diff --git a/apps/reflections/migrations/0003_alter_retrospective_project.py b/apps/reflections/migrations/0003_alter_retrospective_project.py new file mode 100644 index 0000000..a0e7091 --- /dev/null +++ b/apps/reflections/migrations/0003_alter_retrospective_project.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.10 on 2026-02-04 14:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0003_project_is_favorite_project_project_image_and_more"), + ("reflections", "0002_retrospective_bookmarked"), + ] + + operations = [ + migrations.AlterField( + model_name="retrospective", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="retrospectives", + to="projects.project", + ), + ), + ] diff --git a/apps/reflections/migrations/0004_retrospective_answers_json_and_more.py b/apps/reflections/migrations/0004_retrospective_answers_json_and_more.py new file mode 100644 index 0000000..e2d7ccb --- /dev/null +++ b/apps/reflections/migrations/0004_retrospective_answers_json_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.10 on 2026-02-05 15:54 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0003_project_is_favorite_project_project_image_and_more"), + ("reflections", "0003_alter_retrospective_project"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="retrospective", + name="answers_json", + field=models.JSONField( + blank=True, + default=dict, + help_text="질문별 답변 원본(JSON). 값은 마크다운 텍스트 문자열을 권장", + ), + ), + migrations.AddField( + model_name="retrospective", + name="template_key", + field=models.CharField( + default="default", + help_text="회고 질문 템플릿 키 (e.g., default, compact)", + max_length=32, + ), + ), + migrations.AlterField( + model_name="retrospective", + name="content_md", + field=models.TextField(blank=True, default="", help_text="회고 내용 (마크다운)"), + ), + migrations.AddIndex( + model_name="retrospective", + index=models.Index( + fields=["template_key", "created_at"], + name="retrospecti_templat_94e10e_idx", + ), + ), + ] diff --git a/apps/reflections/migrations/0005_retrospectiveasset.py b/apps/reflections/migrations/0005_retrospectiveasset.py new file mode 100644 index 0000000..76b41f2 --- /dev/null +++ b/apps/reflections/migrations/0005_retrospectiveasset.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2.10 on 2026-02-06 05:36 + +import apps.reflections.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reflections", "0004_retrospective_answers_json_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RetrospectiveAsset", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "image", + models.ImageField( + help_text="첨부 이미지", + upload_to=apps.reflections.models.retrospective_asset_upload_to, + ), + ), + ( + "alt_text", + models.CharField( + blank=True, + default="", + help_text="마크다운 이미지 alt 텍스트", + max_length=120, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "retrospective", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="reflections.retrospective", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="retrospective_assets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "retrospective_assets", + "ordering": ["-created_at"], + "indexes": [ + models.Index( + fields=["retrospective", "created_at"], + name="retrospecti_retrosp_f6b2dc_idx", + ), + models.Index( + fields=["user", "created_at"], + name="retrospecti_user_id_382b28_idx", + ), + ], + }, + ), + ] diff --git a/apps/reflections/migrations/0006_retrospectiveasset_draft_key_and_more.py b/apps/reflections/migrations/0006_retrospectiveasset_draft_key_and_more.py new file mode 100644 index 0000000..5a190c7 --- /dev/null +++ b/apps/reflections/migrations/0006_retrospectiveasset_draft_key_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.10 on 2026-02-06 10:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reflections", "0005_retrospectiveasset"), + ] + + operations = [ + migrations.AddField( + model_name="retrospectiveasset", + name="draft_key", + field=models.UUIDField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name="retrospectiveasset", + name="retrospective", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="reflections.retrospective", + ), + ), + ] diff --git a/apps/reflections/migrations/__init__.py b/apps/reflections/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reflections/models.py b/apps/reflections/models.py new file mode 100644 index 0000000..0b80674 --- /dev/null +++ b/apps/reflections/models.py @@ -0,0 +1,141 @@ +# reflections/models.py +import os +import uuid + +from django.conf import settings +from django.db import models + + +class Retrospective(models.Model): + """ + 회고 + - 프로젝트별 개인 회고 작성 + - 마크다운 형식 + """ + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="retrospectives", + null=True, + blank=True, + ) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="retrospectives", + ) + + # 어떤 질문 템플릿으로 작성했는지 (default/compact) + template_key = models.CharField( + max_length=32, + default="default", + help_text="회고 질문 템플릿 키 (e.g., default, compact)", + ) + + title = models.CharField( + max_length=120, + null=True, + blank=True, + help_text="회고 제목", + ) + + # 질문별 답변 원본(JSON): { "q1_work_done": "...md...", ... } + answers_json = models.JSONField( + default=dict, + blank=True, + help_text="질문별 답변 원본(JSON). 값은 마크다운 텍스트 문자열을 권장", + ) + + content_md = models.TextField( + help_text="회고 내용 (마크다운)", + blank=True, + default="", + ) + + bookmarked = models.BooleanField( + default=False, + help_text="찜 여부", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "retrospectives" + indexes = [ + models.Index(fields=["project", "user"]), + models.Index(fields=["created_at"]), + models.Index(fields=["template_key", "created_at"]), + ] + + def __str__(self) -> str: + return f"{self.user} - {self.project}: {self.title or '회고'}" + + +def retrospective_asset_upload_to(instance: "RetrospectiveAsset", filename: str) -> str: + """ + 저장 경로: + media/retrospectives///. + """ + _, ext = os.path.splitext(filename) + ext = (ext or "").lower() + return f"retrospectives/{instance.user_id}/{instance.retrospective_id}/{uuid.uuid4().hex}{ext}" + + +class RetrospectiveAsset(models.Model): + """ + 회고 첨부 이미지 + - 업로드 후 반환되는 image.url을 md 문법으로 본문에 삽입: ![alt](/media/...) + """ + + retrospective = models.ForeignKey( + Retrospective, + on_delete=models.CASCADE, + related_name="assets", + null=True, blank=True, + ) + + # 권한/조회 편의용 (중복이지만 실무에서 유용) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="retrospective_assets", + ) + + # 임시 저장용 키 (회고 작성 중 업로드된 이미지 구분용) + draft_key = models.UUIDField( + null=True, blank=True, db_index=True + ) + + image = models.ImageField( + upload_to=retrospective_asset_upload_to, + help_text="첨부 이미지", + ) + + alt_text = models.CharField( + max_length=120, + blank=True, + default="", + help_text="마크다운 이미지 alt 텍스트", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "retrospective_assets" + indexes = [ + models.Index(fields=["retrospective", "created_at"]), + models.Index(fields=["user", "created_at"]), + ] + ordering = ["-created_at"] + + def save(self, *args, **kwargs): + # retrospective.user와 항상 일치시키기 + if self.retrospective_id and (not self.user_id): + self.user = self.retrospective.user + super().save(*args, **kwargs) + + def __str__(self) -> str: + return f"asset:{self.id} retro:{self.retrospective_id} user:{self.user_id}" diff --git a/apps/reflections/serializers.py b/apps/reflections/serializers.py new file mode 100644 index 0000000..4e04376 --- /dev/null +++ b/apps/reflections/serializers.py @@ -0,0 +1,103 @@ +# reflections/serializers.py +from rest_framework import serializers +from .models import Retrospective, RetrospectiveAsset +from .services.retrospective_guide import load_guide, build_markdown + + +class RetrospectiveReadSerializer(serializers.ModelSerializer): + username = serializers.CharField(source="user.nickname", read_only=True) + project_id = serializers.IntegerField(source="project.id", read_only=True) + + class Meta: + model = Retrospective + fields = [ + "id", + "project_id", + "user", + "username", + "title", + "template_key", + "answers_json", + "content_md", + "bookmarked", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "project_id", + "user", + "username", + "created_at", + "updated_at" + ] + def validate_content_md(self, value): + if not value.strip(): + raise serializers.ValidationError("내용은 비어 있을 수 없습니다.") + return value + + +class RetrospectiveWriteSerializer(serializers.ModelSerializer): + class Meta: + model = Retrospective + fields = ( + "id", + "project", + "title", + "template_key", + "answers_json", + "bookmarked", + ) + extra_kwargs = { + "template_key": {"required": False}, + "answers_json": {"required": False}, + } + + def validate_answers_json(self, v): + if v is None: + return {} + if not isinstance(v, dict): + raise serializers.ValidationError("answers_json은 객체(JSON dict)여야 합니다.") + return v + + def _rebuild_content_md(self, instance_or_data: dict, template_key: str, answers_json: dict, title: str | None): + guide = load_guide(template_key) + return build_markdown(guide, answers_json, title=title) + + def create(self, validated_data): + template_key = validated_data.get("template_key") or "compact" + answers_json = validated_data.get("answers_json") or {} + title = validated_data.get("title") + + validated_data["content_md"] = self._rebuild_content_md( + validated_data, template_key, answers_json, title + ) + return super().create(validated_data) + + def update(self, instance, validated_data): + # 기존 값과 병합해서 md 재생성 + template_key = validated_data.get("template_key", instance.template_key or "compact") + answers_json = validated_data.get("answers_json", instance.answers_json or {}) + title = validated_data.get("title", instance.title) + + validated_data["content_md"] = self._rebuild_content_md( + validated_data, template_key, answers_json, title + ) + return super().update(instance, validated_data) + +class RetrospectiveAssetUploadSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField() + md = serializers.SerializerMethodField() + + class Meta: + model = RetrospectiveAsset + fields = ["id", "alt_text", "image", "url", "md", "created_at"] + read_only_fields = ["id", "url", "md", "created_at"] + + def get_url(self, obj): + return obj.image.url if obj.image else "" + + def get_md(self, obj): + alt = obj.alt_text or "image" + url = self.get_url(obj) + return f"![{alt}]({url})" if url else "" \ No newline at end of file diff --git a/apps/reflections/services/retrospective_guide.py b/apps/reflections/services/retrospective_guide.py new file mode 100644 index 0000000..4f55a23 --- /dev/null +++ b/apps/reflections/services/retrospective_guide.py @@ -0,0 +1,48 @@ +import json +from pathlib import Path +from django.apps import apps + +APP_PATH = Path(apps.get_app_config("reflections").path) +GUIDE_DIR = APP_PATH / "guide_templates" +ALLOWED_TPLS = {"default", "compact"} # 지금은 default만 쓰면 {"default"}로 + +def load_guide(template_key: str) -> dict: + """ + ### load_guide : {질문}.json 파일을 읽고 dict 형식으로 리턴 + :param tpl:str : retrospective_{tpl}.json 형식으로 읽을 질문 템플릿 지정 + :return -> dict: .json 파일을 변환한 dict + """ + if template_key not in ALLOWED_TPLS: + template_key = "default" + path = GUIDE_DIR / f"retrospective_{template_key}.json" + return json.loads(path.read_text(encoding="utf-8")) + +def build_markdown(guide: dict, answers: dict, title: str | None = None) -> str: + lines = [] + title_text = title.strip() if title else guide.get("title", "회고") + lines.append(f"# 📝 {title_text}") + lines.append("") + + intro = guide.get("intro") or [] + if intro: + lines.append("> " + "\n> ".join(intro)) + lines.append("") + lines.append("---") + lines.append("") + + questions = sorted(guide.get("questions", []), key=lambda x: x.get("order", 0)) + for q in questions: + order = q.get("order") + qtitle = q.get("title", "") + if order: + lines.append(f"## {order} {qtitle}") + else: + lines.append(f"## {qtitle}") + lines.append("") + ans = (answers.get(q.get("id")) or "").strip() + lines.append(ans if ans else "_(작성 내용 없음)_") + lines.append("") + lines.append("---") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" diff --git a/apps/reflections/signals.py b/apps/reflections/signals.py new file mode 100644 index 0000000..a775680 --- /dev/null +++ b/apps/reflections/signals.py @@ -0,0 +1,9 @@ +# reflections/signals.py +from django.db.models.signals import post_delete +from django.dispatch import receiver +from .models import RetrospectiveAsset + +@receiver(post_delete, sender=RetrospectiveAsset) +def delete_asset_file(sender, instance, **kwargs): + if instance.image: + instance.image.delete(save=False) diff --git a/apps/reflections/templatetags/__init__.py b/apps/reflections/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reflections/templatetags/reflections_extras.py b/apps/reflections/templatetags/reflections_extras.py new file mode 100644 index 0000000..f545daf --- /dev/null +++ b/apps/reflections/templatetags/reflections_extras.py @@ -0,0 +1,122 @@ +from django import template +from django.utils.safestring import mark_safe + +import markdown +import bleach +import re + +register = template.Library() + +def _ensure_class(html: str, tag: str, cls: str) -> str: + # 1) class가 이미 있는 경우: 기존 class 뒤에 추가(중복 방지) + def repl_with_class(m): + before, classes, after = m.group(1), m.group(2), m.group(3) + class_list = classes.split() + if cls not in class_list: + class_list.append(cls) + return f'<{tag}{before}class="{" ".join(class_list)}"{after}' + + html = re.sub( + rf"<{tag}([^>]*?)class=\"([^\"]*)\"([^>]*?)>", + repl_with_class, + html, + flags=re.IGNORECASE, + ) + + # 2) class가 없는 경우: 새로 추가 + html = re.sub( + rf"<{tag}(\s|>)", + rf'<{tag} class="{cls}"\1', + html, + flags=re.IGNORECASE, + ) + return html + +def _add_classes(html: str) -> str: + # table + html = _ensure_class(html, "table", "md-table") + html = _ensure_class(html, "thead", "md-thead") + html = _ensure_class(html, "tbody", "md-tbody") + html = _ensure_class(html, "tr", "md-tr") + html = _ensure_class(html, "th", "md-th") + html = _ensure_class(html, "td", "md-td") + + # images + html = _ensure_class(html, "img", "md-img") + + # code blocks + html = _ensure_class(html, "pre", "md-pre") + html = _ensure_class(html, "code", "md-code") + + # blockquote / lists + html = _ensure_class(html, "blockquote", "md-quote") + html = _ensure_class(html, "ul", "md-ul") + html = _ensure_class(html, "ol", "md-ol") + html = _ensure_class(html, "li", "md-li") + + # headings + html = _ensure_class(html, "h1", "md-h1") + html = _ensure_class(html, "h2", "md-h2") + html = _ensure_class(html, "h3", "md-h3") + + # paragraphs + html = _ensure_class(html, "p", "md-p") + + return html + +def _wrap_tables(html: str) -> str: + # table을 md-table-wrap로 감쌈 (이미 감싸져 있으면 중복 방지 정도는 추가 가능) + return re.sub( + r'(]*>.*?)', + r'
\1
', + html, + flags=re.IGNORECASE | re.DOTALL, + ) + +@register.filter(name="get_item") +def get_item(d, key): + if not d: + return "" + return d.get(key, "") + +@register.filter(name="md") +def md(value): + if not value: + return "" + + raw_html = markdown.markdown( + value, + extensions=[ + "fenced_code", # ``` 코드블록 + "tables", + "nl2br", # 줄바꿈 + ], + ) + + allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({ + "p","br","hr", + "h1","h2","h3","h4","h5","h6", + "pre","code","blockquote", + "ul","ol","li", + "strong","em", + "a","img", + # 테이블 관련 태그 허용 + "table","thead","tbody","tr","th","td", + }) + allowed_attrs = { + "a": ["href", "title", "rel", "target"], + "img": ["src", "alt", "title"], + "*": ["class"], + } + + cleaned = bleach.clean( + raw_html, + tags=allowed_tags, + attributes=allowed_attrs, + protocols=["http", "https", "data"], + strip=True, + ) + cleaned = bleach.linkify(cleaned) + cleaned = _add_classes(cleaned) + cleaned = _wrap_tables(cleaned) + return mark_safe(cleaned) diff --git a/apps/reflections/tests.py b/apps/reflections/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/reflections/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/reflections/urls.py b/apps/reflections/urls.py new file mode 100644 index 0000000..45e4f29 --- /dev/null +++ b/apps/reflections/urls.py @@ -0,0 +1,21 @@ +from django.urls import path +from . import views + +app_name = "reflections" + +urlpatterns = [ + # 회고 목록 + path("", views.note_list, name="note_list"), # note_list.html + + # 회고 작성 + path("create/", views.note_create, name="note_create"), # note_create.html + + # 회고 상세 + path("/", views.note_detail, name="note_detail"), # note_detail.html + + # 회고 수정 + path("/edit/", views.note_update, name="note_update"), # note_update.html + + # 회고 삭제 + path("/delete/", views.note_delete, name="note_delete"), +] diff --git a/apps/reflections/views.py b/apps/reflections/views.py new file mode 100644 index 0000000..8026939 --- /dev/null +++ b/apps/reflections/views.py @@ -0,0 +1,554 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.db.models import Q + +import uuid +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.exceptions import PermissionDenied, NotAuthenticated +from drf_spectacular.utils import ( + extend_schema_view, + extend_schema, + OpenApiParameter, + OpenApiTypes, +) + +from .models import Retrospective, RetrospectiveAsset +from .serializers import ( + RetrospectiveReadSerializer, + RetrospectiveWriteSerializer, + RetrospectiveAssetUploadSerializer, +) +from .services.retrospective_guide import load_guide, build_markdown + +from apps.projects.models import Project +from apps.teams.models import TeamMember + + +def _get_my_projects_and_roles(user): + """ + 내 프로젝트와 프로젝트에서의 내 역할 찾기 + """ + my_project_ids = ( + TeamMember.objects + .filter(user=user) + .values_list("team__project_id", flat=True) + .distinct() + ) + + my_projects = ( + Project.objects + .filter(Q(id__in=my_project_ids) | Q(owner=user)) + .order_by("title") + ) + + # 프로젝트별 내 role 코드(TeamMember.role.code) 매핑 + role_map = {} + tm_qs = ( + TeamMember.objects + .filter(user=user, team__project__in=my_projects) + .select_related("role", "team__project") + ) + for tm in tm_qs: + pid = tm.team.project_id + # 같은 프로젝트에 팀멤버가 여러개면(이상 케이스) 첫 값 유지 + role_map.setdefault(pid, getattr(tm.role, "code", None)) + + return my_projects, role_map + + +@login_required +def note_list(request): + """ + 회고 목록 조회 + + 쿼리스트링: + :q: 검색 + :roles: 스택 필터링 ("BACKEND", "PM" 식의 복수 선택 가능, none은 개인 회고 조회) + :bookmarked: 북마크 필터링 + :sort: 정렬 키워드 (new, old, title) + """ + qs = ( + Retrospective.objects + .filter(user = request.user) # 해당 유저의 회고만 + .select_related("project") # + .order_by("-created_at") # 시간 최신순 + ) + + # 검색(제목/본문/프로젝트명) + q = request.GET.get("q", "").strip() + if q: + qs = qs.filter( + Q(title__icontains=q) | + Q(content_md__icontains=q) | + Q(project__title__icontains=q) + ) + + # 스택 필터 + role = (request.GET.get("roles") or "").strip() + + if role == "none": + qs = qs.filter(project__isnull=True) + + elif role in ("PM", "FRONTEND", "BACKEND"): + role_project_ids = ( + TeamMember.objects + .filter(user=request.user, role__code=role) + .values_list("team__project_id", flat=True) + .distinct() + ) + # ✅ 매칭 프로젝트가 없으면 결과 0개가 맞음 + qs = qs.filter(project_id__in=role_project_ids) + + # 북마크 필터 + bookmarked = request.GET.get("bookmarked") + if bookmarked in ("1", "true", "True"): + qs = qs.filter(bookmarked=True) + + # 정렬 + sort = request.GET.get("sort", "new") + if sort == "old": + qs = qs.order_by("created_at") + elif sort == "title": + qs = qs.order_by("title") + else: + qs = qs.order_by("-created_at") + + my_project_ids = ( + TeamMember.objects + .filter(user=request.user) + .values_list("team__project_id", flat=True) + .distinct() + ) + + my_projects = ( + Project.objects + .filter(Q(id__in=my_project_ids) | Q(owner=request.user)) + .order_by("title") + ) + + context = { + "notes" : qs, + "my_projects": my_projects, # 내 프로젝트 조회 -> 필터에 보여주기 + "role": role, + "q" : q, + "bookmarked": bookmarked, + "sort": sort, + } + return render(request, "reflections/note_list.html", context) + + +@login_required +def note_create(request): + """ + 회고 작성 + + 쿼리스트링: + :tpl: 선택할 질문 템플릿 (현재는 default 하나만) + + """ + tpl_key = request.GET.get("tpl") or "compact" + guide = load_guide(tpl_key) + + # ✅ draft_key 발급/유지 + if "retro_draft_key" not in request.session: + request.session["retro_draft_key"] = str(uuid.uuid4()) + draft_key = request.session["retro_draft_key"] + + my_projects, my_role_map = _get_my_projects_and_roles(request.user) + + if request.method == "POST": + title = (request.POST.get("title") or "빈 제목").strip() + project_id_raw = (request.POST.get("project_id") or "").strip() + role_code = (request.POST.get("role") or "").strip() + + if not title: + context = {"guide": guide, "tpl": tpl_key, "error": "제목은 필수입니다."} + return render(request, "reflections/note_create.html", context) + + project = None + if project_id_raw: + project = get_object_or_404(Project, id=project_id_raw) + + # 보안/권한: 내 프로젝트가 아니면 막기 + if project not in my_projects: + messages.error(request, "내 프로젝트만 선택할 수 있습니다.") + return redirect("reflections:note_create") + + # role 자동 채움 정책: role이 비어있으면 프로젝트 기준 role_map에서 채움 + if not role_code: + role_code = my_role_map.get(project.id) or "" + + answers = dict() # qid: "답변 내용" 형식 + for q in guide["questions"]: + qid = q["id"] + answers[qid] = (request.POST.get(f"a__{qid}") or "빈 답변 내용").strip() + + content_md = build_markdown(guide, answers) + + note = Retrospective.objects.create( + user= request.user, + project=project, + template_key=tpl_key, + title=title, + answers_json=answers, + content_md = content_md, + ) + + # ✅ draft로 업로드된 이미지들을 note에 연결 + RetrospectiveAsset.objects.filter( + user=request.user, + draft_key=draft_key, + retrospective__isnull=True, + ).update(retrospective=note, draft_key=None) + + # ✅ draft_key 정리 + request.session.pop("retro_draft_key", None) + + return redirect("reflections:note_list") + context = { + "guide": guide, + "tpl": tpl_key, + "answers": {}, + "draft_key": draft_key, + "my_projects": my_projects, + "my_role_map": my_role_map, + "note": None + } + return render(request, "reflections/note_create.html", context) + + +@login_required +def note_detail(request, note_id): + """회고 상세""" + # TODO: 회고 상세 로직 구현 + note = get_object_or_404(Retrospective, id=note_id, user=request.user) + context = { + "note": note, + "guide": load_guide(note.template_key), + "answers": note.answers_json or {}, + } + return render(request, "reflections/note_detail.html", context) + + +@login_required +def note_update(request, note_id): + """회고 수정 - note_create와 동일하게 guide 기반으로 렌더/저장""" + note = get_object_or_404(Retrospective, id=note_id, user=request.user) + + tpl = note.template_key or "compact" + guide = load_guide(tpl) + + # 기존 답변(answers_json)로 textarea 기본값 채우기 + existing_answers = note.answers_json or {} + + my_projects, my_role_map = _get_my_projects_and_roles(request.user) + + if request.method == "POST": + title = (request.POST.get("title") or "빈 제목").strip() + project_id_raw = (request.POST.get("project_id") or "").strip() + role_code = (request.POST.get("role") or "").strip() + + if not title: + context = { + "note": note, + "guide": guide, + "tpl": tpl, + "answers": existing_answers, + "error": "제목은 필수입니다.", + } + return render(request, "reflections/note_update.html", context) + + project = None + if project_id_raw: + project = get_object_or_404(Project, id=project_id_raw) + if project not in my_projects: + messages.error(request, "내 프로젝트만 선택할 수 있습니다.") + return redirect("reflections:note_update", note_id=note.id) + + # role 비어있으면 자동, 있으면 사용자가 고른 값 존중 + if not role_code: + role_code = my_role_map.get(project.id) or "" + + answers = {} + for q in guide["questions"]: + qid = q["id"] + answers[qid] = (request.POST.get(f"a__{qid}") or "").strip() + + content_md = build_markdown(guide, answers) + + note.title = title + note.project = project + note.answers_json = answers + note.content_md = content_md + note.save(update_fields=["title", "answers_json", "content_md", "updated_at"]) + + return redirect("reflections:note_detail", note_id=note.id) + + context = { + "note": note, + "guide": guide, + "tpl": tpl, + "answers": existing_answers, + "my_projects": my_projects or {}, + "my_role_map": my_role_map or [], + } + return render(request, "reflections/note_update.html", context) + + + +@login_required +def note_delete(request, note_id): + """회고 삭제""" + # TODO: 회고 삭제 로직 구현 + note = get_object_or_404(Retrospective, id=note_id, user=request.user) + if request.method == "POST": + note.delete() + messages.success(request, "회고가 삭제되었습니다.") + return redirect("reflections:note_list") + return redirect("reflections:note_detail", note_id=note_id) + + +@extend_schema_view( + list=extend_schema( + summary="회고 목록 조회", + tags=["Retrospectives"], + parameters=[ + OpenApiParameter( + name="q", + type=OpenApiTypes.STR, + required=False, + location=OpenApiParameter.QUERY, + description="검색 (title/content_md/project.name 부분일치)", + ), + OpenApiParameter( + name="roles", + type=OpenApiTypes.STR, + required=False, + location=OpenApiParameter.QUERY, + description='스택 필터(복수 가능). 예: roles=BACKEND&roles=PM 또는 roles=none(개인회고)', + many=True, + ), + OpenApiParameter( + name="bookmarked", + type=OpenApiTypes.STR, + required=False, + location=OpenApiParameter.QUERY, + description='북마크 필터. true/1/True면 bookmarked=True', + ), + OpenApiParameter( + name="sort", + type=OpenApiTypes.STR, + required=False, + location=OpenApiParameter.QUERY, + description="정렬 (new, old, title). 기본 new", + ), + ], + ), + retrieve=extend_schema(summary="회고 상세 조회", tags=["Retrospectives"]), + create=extend_schema(summary="회고 생성", tags=["Retrospectives"]), + update=extend_schema(summary="회고 전체 수정", tags=["Retrospectives"]), + partial_update=extend_schema(summary="회고 부분 수정", tags=["Retrospectives"]), + destroy=extend_schema(summary="회고 삭제", tags=["Retrospectives"]), +) +class RetrospectiveViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + if self.action in ("list", "retrieve"): + return RetrospectiveReadSerializer + return RetrospectiveWriteSerializer + + def get_queryset(self): + u = self.request.user + if not u.is_authenticated: + return Retrospective.objects.none() + + # base qs + qs = ( + Retrospective.objects + .filter(user=u) # 해당 유저의 회고만 + .select_related("project", "user") + .order_by("-created_at") # 기본 최신순 + ) + + # 검색(제목/본문/프로젝트명) + q = (self.request.query_params.get("q") or "").strip() + if q: + qs = qs.filter( + Q(title__icontains=q) | + Q(content_md__icontains=q) | + Q(project__title__icontains=q) + ) + + # 스택 필터(roles=BACKEND&roles=PM&roles=none ...) + role_codes = self.request.query_params.getlist("roles") + if role_codes: + get_personal_retro = "none" in role_codes + + # 원본 로직 그대로: TeamMember에서 role__code로 필터, project_id 목록 추출 + role_project_ids = ( + TeamMember.objects + .filter(user=u, role__code__in=role_codes) + .values_list("team__project_id", flat=True) + .distinct() + ) + + if get_personal_retro and role_project_ids: + qs = qs.filter(Q(project__isnull=True) | Q(project_id__in=role_project_ids)) + elif get_personal_retro: + qs = qs.filter(project__isnull=True) + elif role_project_ids: + qs = qs.filter(project_id__in=role_project_ids) + else: + # roles는 있는데 매칭되는 project가 하나도 없고 none도 없으면 결과 없음 + qs = qs.none() + + # 북마크 필터 + bookmarked = self.request.query_params.get("bookmarked") + if bookmarked in ("1", "true", "True"): + qs = qs.filter(bookmarked=True) + + # 정렬 + sort = self.request.query_params.get("sort", "new") + if sort == "old": + qs = qs.order_by("created_at") + elif sort == "title": + qs = qs.order_by("title") + else: + qs = qs.order_by("-created_at") + + return qs + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def get_object(self): + if not self.request.user.is_authenticated: + raise NotAuthenticated() + obj = super().get_object() + if obj.user_id != self.request.user.id: + raise PermissionDenied("본인 회고만 접근 가능합니다.") + return obj + + def create(self, request, *args, **kwargs): + # 생성 후 ReadSerializer로 응답(원하면 제거 가능) + write = RetrospectiveWriteSerializer(data=request.data, context=self.get_serializer_context()) + write.is_valid(raise_exception=True) + obj = write.save(user=request.user) + read = RetrospectiveReadSerializer(obj, context=self.get_serializer_context()) + return Response(read.data, status=status.HTTP_201_CREATED) + + @extend_schema( + summary="회고 이미지 업로드", + tags=["Retrospectives"], + request={ + "multipart/form-data": { + "type": "object", + "properties": { + "image": {"type": "string", "format": "binary"}, + "alt_text": {"type": "string"}, + }, + "required": ["image"], + } + }, + responses={201: RetrospectiveAssetUploadSerializer}, + ) + @action( + detail=True, + methods=["post"], + url_path="assets", + parser_classes=[MultiPartParser, FormParser], + ) + def upload_asset(self, request, pk=None): + retro = self.get_object() # 여기서 본인 회고 체크됨 + + f = request.FILES.get("image") + if not f: + return Response({"detail": "image 파일이 필요합니다."}, status=400) + + # 간단한 이미지 타입 체크(추가 안전장치) + ct = (getattr(f, "content_type", "") or "").lower() + if ct and not ct.startswith("image/"): + return Response({"detail": "이미지 파일만 업로드 가능합니다."}, status=400) + + alt_text = (request.data.get("alt_text") or "").strip() + + asset = RetrospectiveAsset.objects.create( + retrospective=retro, + user=request.user, + image=f, + alt_text=alt_text, + ) + + data = RetrospectiveAssetUploadSerializer(asset, context=self.get_serializer_context()).data + return Response(data, status=status.HTTP_201_CREATED) + + @extend_schema( + summary="회고 이미지 삭제", + tags=["Retrospectives"] + ) + @action( + detail=True, + methods=["delete"], + url_path=r"assets/(?P\d+)" + ) + def delete_asset(self, request, pk=None, asset_id=None): + retro = self.get_object() # 본인 회고인지 포함해서 체크된다고 가정 + + asset = RetrospectiveAsset.objects.filter( + id=asset_id, + retrospective=retro, + user=request.user, + ).first() + if not asset: + return Response({"detail": "asset not found"}, status=404) + + asset.delete() # ✅ 여기서 DB 삭제 + (아래 시그널/오버라이드 있으면 파일도 삭제) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="회고 이미지 임시 업로드", + tags=["Retrospectives"] + ) + @action( + detail=False, + methods=["post"], + url_path="assets/temp", + parser_classes=[MultiPartParser, FormParser], + ) + def upload_temp_asset(self, request): + draft_key = request.data.get("draft_key") + if not draft_key: + return Response({"detail": "draft_key 필요"}, status=400) + + try: + draft_uuid = uuid.UUID(str(draft_key)) + except ValueError: + return Response({"detail": "draft_key 형식 오류"}, status=400) + + f = request.FILES.get("image") + if not f: + return Response({"detail": "image 파일 필요"}, status=400) + + ct = (getattr(f, "content_type", "") or "").lower() + if ct and not ct.startswith("image/"): + return Response({"detail": "이미지 파일만 업로드 가능합니다."}, status=400) + + alt_text = (request.data.get("alt_text") or "").strip() + + asset = RetrospectiveAsset.objects.create( + user=request.user, + draft_key=draft_uuid, + retrospective=None, + image=f, + alt_text=alt_text, + ) + + url = asset.image.url + md = f"![{alt_text or 'image'}]({url})" + return Response({"id": asset.id, "url": url, "md": md}, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/apps/teams/__init__.py b/apps/teams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/teams/admin.py b/apps/teams/admin.py new file mode 100644 index 0000000..c850e4e --- /dev/null +++ b/apps/teams/admin.py @@ -0,0 +1,66 @@ +from django.contrib import admin +from django.contrib import messages +from django.utils import timezone +from django.utils.translation import ngettext + +from .models import Team, TeamMember + + +class TeamMemberInline(admin.TabularInline): + model = TeamMember + extra = 0 + fields = ["user", "role", "is_active", "joined_at", "left_at"] + readonly_fields = ["joined_at"] + + +@admin.register(Team) +class TeamAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project", "created_at"] + search_fields = ["name", "project__title"] + ordering = ["-created_at"] + inlines = [TeamMemberInline] + + +@admin.register(TeamMember) +class TeamMemberAdmin(admin.ModelAdmin): + list_display = ["id", "team", "user", "role", "is_active", "joined_at"] + list_filter = ["role", "is_active"] + search_fields = ["user__nickname", "team__name"] + ordering = ["-joined_at"] + actions = ["deactivate_members", "reactivate_members"] + + def deactivate_members(self, request, queryset): + """팀 멤버 비활성화 (탈퇴 처리)""" + now = timezone.now() + count = 0 + + for member in queryset.filter(is_active=True): + member.is_active = False + member.left_at = now + member.save() + count += 1 + + self.message_user( + request, + ngettext( + f"{count}명이 팀에서 제거되었습니다.", + f"{count}명이 팀에서 제거되었습니다.", + count, + ), + messages.SUCCESS, + ) + deactivate_members.short_description = "🚪 선택된 멤버 비활성화 (탈퇴)" + + def reactivate_members(self, request, queryset): + """팀 멤버 재활성화""" + count = queryset.filter(is_active=False).update(is_active=True, left_at=None) + self.message_user( + request, + ngettext( + f"{count}명이 팀에 재입장했습니다.", + f"{count}명이 팀에 재입장했습니다.", + count, + ), + messages.SUCCESS, + ) + reactivate_members.short_description = "🔄 선택된 멤버 재활성화" diff --git a/apps/teams/api_urls.py b/apps/teams/api_urls.py new file mode 100644 index 0000000..32b89dd --- /dev/null +++ b/apps/teams/api_urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import TeamViewSet, TeamMemberViewSet, passion_submit_api + +router = DefaultRouter() +router.register(r'teams', TeamViewSet, basename='team') +router.register(r'team-members', TeamMemberViewSet, basename='team-member') + +urlpatterns = [ + path('', include(router.urls)), + path('passion-test/submit/', passion_submit_api, name='passion_submit_api'), +] diff --git a/apps/teams/apps.py b/apps/teams/apps.py new file mode 100644 index 0000000..c56e927 --- /dev/null +++ b/apps/teams/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TeamsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.teams" + label = "teams" diff --git a/apps/teams/migrations/0001_initial.py b/apps/teams/migrations/0001_initial.py new file mode 100644 index 0000000..b56768a --- /dev/null +++ b/apps/teams/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.10 on 2026-01-30 05:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, help_text='팀 이름', max_length=80, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('project', models.OneToOneField(help_text='연결된 프로젝트 (1:1)', on_delete=django.db.models.deletion.CASCADE, related_name='team', to='projects.project')), + ], + options={ + 'db_table': 'teams', + }, + ), + migrations.CreateModel( + name='TeamMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(default=True, help_text='활성 상태 (탈퇴 시 False)')), + ('joined_at', models.DateTimeField(auto_now_add=True)), + ('left_at', models.DateTimeField(blank=True, help_text='탈퇴 일시', null=True)), + ('role', models.ForeignKey(help_text='배정된 역할', on_delete=django.db.models.deletion.PROTECT, related_name='team_members', to='accounts.role')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='teams.team')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'team_members', + 'indexes': [models.Index(fields=['team', 'role'], name='team_member_team_id_a23ddb_idx'), models.Index(fields=['user'], name='team_member_user_id_906d63_idx')], + 'constraints': [models.UniqueConstraint(fields=('team', 'user'), name='uq_team_member')], + }, + ), + ] diff --git a/apps/teams/migrations/__init__.py b/apps/teams/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/teams/models.py b/apps/teams/models.py new file mode 100644 index 0000000..6d4e8ea --- /dev/null +++ b/apps/teams/models.py @@ -0,0 +1,94 @@ +from django.conf import settings +from django.db import models + + +class Team(models.Model): + """ + 팀 + - 1 project = 1 team (1:1 관계) + - 팀 구성: PM 1명 / FE 2명 / BE 2명 (총 5명) + """ + + project = models.OneToOneField( + "projects.Project", + on_delete=models.CASCADE, + related_name="team", + help_text="연결된 프로젝트 (1:1)", + ) + + name = models.CharField( + max_length=80, + null=True, + blank=True, + help_text="팀 이름", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "teams" + + def __str__(self) -> str: + return self.name or f"Team#{self.id}" + + def get_member_count_by_role(self) -> dict: + """역할별 현재 멤버 수 반환""" + from django.db.models import Count + counts = self.members.filter(is_active=True).values("role__code").annotate(count=Count("id")) + return {item["role__code"]: item["count"] for item in counts} + + +class TeamMember(models.Model): + """ + 팀 멤버 + - 역할별로 배정 + - PM 1명 / FE 2명 / BE 2명 구성은 서비스 로직에서 검증 + """ + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="members", + ) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="team_memberships", + ) + + role = models.ForeignKey( + "accounts.Role", + on_delete=models.PROTECT, + related_name="team_members", + help_text="배정된 역할", + ) + + is_active = models.BooleanField( + default=True, + help_text="활성 상태 (탈퇴 시 False)", + ) + + joined_at = models.DateTimeField(auto_now_add=True) + + left_at = models.DateTimeField( + null=True, + blank=True, + help_text="탈퇴 일시", + ) + + class Meta: + db_table = "team_members" + constraints = [ + models.UniqueConstraint( + fields=["team", "user"], + name="uq_team_member", + ), + ] + indexes = [ + models.Index(fields=["team", "role"]), + models.Index(fields=["user"]), + ] + + def __str__(self) -> str: + return f"{self.user} @ {self.team} ({self.role.code})" diff --git a/apps/teams/serializers.py b/apps/teams/serializers.py new file mode 100644 index 0000000..cfb85de --- /dev/null +++ b/apps/teams/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers +from .models import Team, TeamMember + + +class TeamMemberSerializer(serializers.ModelSerializer): + """팀 멤버 Serializer""" + username = serializers.CharField(source='user.nickname', read_only=True) + role_code = serializers.CharField(source='role.code', read_only=True) + role_name = serializers.CharField(source='role.name', read_only=True) + + class Meta: + model = TeamMember + fields = ['id', 'team', 'user', 'username', 'role', 'role_code', 'role_name', 'is_active', 'joined_at', 'left_at'] + read_only_fields = ['id', 'joined_at'] + + +class TeamSerializer(serializers.ModelSerializer): + """팀 기본 Serializer""" + members = TeamMemberSerializer(many=True, read_only=True) + project_title = serializers.CharField(source='project.title', read_only=True) + member_count = serializers.SerializerMethodField() + + class Meta: + model = Team + fields = [ + 'id', 'name', 'project', 'project_title', + 'created_at', 'members', 'member_count' + ] + read_only_fields = ['id', 'created_at'] + + def get_member_count(self, obj) -> int: + return obj.members.filter(is_active=True).count() + + +class TeamCreateSerializer(serializers.ModelSerializer): + """팀 생성용 Serializer""" + + class Meta: + model = Team + fields = ['id', 'name', 'project'] + read_only_fields = ['id'] diff --git a/apps/teams/tests.py b/apps/teams/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/teams/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/teams/urls.py b/apps/teams/urls.py new file mode 100644 index 0000000..679e59b --- /dev/null +++ b/apps/teams/urls.py @@ -0,0 +1,27 @@ +from django.urls import path +from . import views + +app_name = "teams" + +urlpatterns = [ + # 소속팀 여부에 따라 이동 url 상이 (team/team_apply) + path('matching/', views.team_matching_router, name='matching_router'), + + # 팀 매칭 신청 + path("apply/", views.team_apply, name="team_apply"), # team_apply.html + + # 열정 테스트 (팀플 신청 시) + path("passion-test/", views.passion_test, name="passion_test"), # passion_test.html + + # 열정 테스트 결과 제출 + path("passion-submit/", views.passion_submit, name="passion_submit"), + + # 팀 매칭 신청 취소 + path("cancel/", views.team_matching_cancel, name="team_matching_cancel"), + + # 팀 매칭 결과/대기 화면 + path("status/", views.team_status, name="team_status"), # team.html + + # 이메일 알림 활성화 + path("enable-notifications/", views.enable_email_notifications, name="enable_notifications"), +] diff --git a/apps/teams/views.py b/apps/teams/views.py new file mode 100644 index 0000000..bbede1c --- /dev/null +++ b/apps/teams/views.py @@ -0,0 +1,308 @@ +from django.http import HttpResponseBadRequest +from django.shortcuts import get_object_or_404, render, redirect +from django.contrib.auth.decorators import login_required +from django.contrib import messages + + +from rest_framework import viewsets +from django.utils import timezone +from rest_framework.response import Response +from rest_framework.decorators import action +from drf_spectacular.utils import extend_schema, extend_schema_view + +import json +from django.http import JsonResponse +from django.views.decorators.http import require_POST + +from apps.accounts.models import Role, UserRoleLevel +from apps.projects.models import Season + +from .models import Team, TeamMember +from .serializers import TeamSerializer, TeamCreateSerializer, TeamMemberSerializer + + +# ================================ +# Template Views (HTML 렌더링) +# ================================ + +@login_required +@require_POST +def enable_email_notifications(request): + """ + 사용자의 이메일 알림을 활성화하고 team_apply로 리다이렉트 + """ + user = request.user + user.email_notifications_enabled = True + user.save() + + messages.success(request, "✅ 알림을 활성화했습니다.") + return redirect("teams:team_apply") + + +@login_required +def team_matching_router(request): + """ + 사용자의 상태를 확인하여 매칭 신청 페이지 또는 결과 페이지로 보냄 + """ + season = Season.get_active_season() + + # 1. 사용자가 이미 팀에 속해 있는지 확인 + user_has_team = TeamMember.objects.filter(user=request.user).exists() + + # 2. 팀이 있다면 결과 페이지(team.html)로 이동 + if user_has_team: + return redirect('teams:team_status') + + # 3. 팀이 없다면 신청 페이지(team_apply.html)로 이동 + return redirect('teams:team_apply') + +@login_required +def team_apply(request): + """ + 팀 매칭 신청 페이지 + + - 활성화된 시즌 확인 + - 팀매칭 기간인지 확인 + - 유저의 역할별 레벨 정보를 함께 전달 + - 'teams/team_apply.html' 템플릿을 렌더링 + - 딕셔너리 형태로 역할 코드와 UserRoleLevel 객체 전달 + - is_matching_period 플래그로 분기 처리 + """ + user = request.user + season = Season.get_active_season() + + # 유저의 역할별 레벨 + role_levels = ( + UserRoleLevel.objects + .filter(user=user) + .select_related("role") + ) + + role_level_map = { + rl.role.code: rl.level + for rl in role_levels + } + + # 팀 매칭 기간 여부 + is_matching_period = season and season.is_matching_period() if season else False + + context = { + "user_obj": user, + "role_levels": role_level_map, + "season": season, + "is_matching_period": is_matching_period, + } + + return render(request, "teams/team_apply.html", context) + + +@login_required +def passion_test(request): + """ + 열정 테스트 페이지 + + - 열정 레벨이 이미 있으면 team_status로 리다이렉트 + - 없으면 'teams/passion_test.html' 템플릿을 렌더링 + - URL 파라미터 ?role=PM|FRONTEND|BACKEND에서 선택 역할 받음 + """ + if request.user.passion_level: + # 이미 열정 테스트 완료 + return redirect("teams:team_status") + + # URL 파라미터에서 role 받기 + role = request.GET.get("role", "") + + context = { + "role": role, + } + + return render(request, "teams/passion_test.html", context) + +@login_required +@require_POST +def passion_submit_api(request): + """ + 열정 테스트 결과 제출 처리 (API) + + - POST 요청으로 passion_level과 role(선호 직군)을 JSON으로 받음 + - User 모델에 열정 레벨과 선호 역할 저장 + - JSON 응답으로 success 여부 반환 + """ + try: + data = json.loads(request.body) + passion_level = data.get("passion_level") + role_code = data.get("role") # PM, FRONTEND, BACKEND + + if passion_level is None: + return JsonResponse({"success": False, "error": "필수 데이터가 없습니다."}) + + request.user.passion_level = int(passion_level) + + # preferred_role 저장 + if role_code: + try: + from apps.accounts.models import Role + role = Role.objects.get(code=role_code) + request.user.preferred_role = role + except Role.DoesNotExist: + pass # 역할이 없으면 무시 + + request.user.save(update_fields=["passion_level", "preferred_role"]) + + return JsonResponse({"success": True}) + except json.JSONDecodeError: + return JsonResponse({"success": False, "error": "잘못된 JSON 형식입니다."}) + except Exception as e: + import traceback + traceback.print_exc() + return JsonResponse({"success": False, "error": str(e)}) + + +@login_required +def passion_submit(request): + """ + 열정 테스트 결과 제출 처리 + + - POST 요청으로 열정 레벨(passion_level)과 역할(role)을 전달받음 + - User 모델에 열정 레벨과 선호 역할 저장 + - 제출 후 팀 매칭 결과 페이지로 리다이렉트 + """ + if request.method != "POST": + return HttpResponseBadRequest("잘못된 요청입니다.") + + passion_level = request.POST.get("passion_level") + role_code = request.POST.get("role") # PM, FRONTEND, BACKEND + + request.user.passion_level = int(passion_level) + + # preferred_role 저장 + if role_code: + try: + from apps.accounts.models import Role + role = Role.objects.get(code=role_code) + request.user.preferred_role = role + except Role.DoesNotExist: + pass # 역할이 없으면 무시 + + request.user.save(update_fields=["passion_level", "preferred_role"]) + + return redirect("teams:team_status") + +@login_required +def team_matching_cancel(request): + """ + 팀 매칭 신청 취소 + + - 팀 매칭 기간 중에만 취소 가능 + - 프로젝트 기간이면 취소 불가능 + - 사용자의 TeamMember 레코드 삭제 + - passion_level을 NULL로 초기화 (다시 열정 테스트 강제) + """ + if request.method != "POST": + return HttpResponseBadRequest("잘못된 요청입니다.") + + season = Season.get_active_season() + + # 팀 매칭 기간이 아니면 취소 불가능 + if not season or not season.is_matching_period(): + messages.error(request, "❌ 팀 매칭 기간이 아닙니다. 취소할 수 없습니다.") + return redirect("teams:team_status") + + # 열정 레벨, preferred_role 초기화 및 이메일 알림 비활성화 + request.user.passion_level = None + request.user.preferred_role = None + request.user.email_notifications_enabled = False + request.user.save(update_fields=["passion_level", "preferred_role", "email_notifications_enabled"]) + + messages.success(request, "✅ 팀 매칭 신청이 취소되었습니다.") + return redirect("teams:team_apply") + + +@login_required +def team_status(request): + """ + 팀 매칭 결과/대기 페이지 + + - 팀 매칭 기간: 매칭 대기 화면 + - 프로젝트 기간: 팀원 정보 화면 + - 'teams/team.html' 템플릿을 렌더링 + - is_matching_period 플래그로 분기 처리 + - 팀 매칭 여부(team_matched) 전달 + """ + season = Season.get_active_season() + team = None + team_members_data = [] + team_matched = False + + if season: + # 현재 사용자의 팀 조회 + team = Team.objects.filter( + members__user=request.user + ).prefetch_related( + 'members__user', + 'members__role' + ).distinct().first() + + # 팀이 존재하면 매칭됨 + team_matched = team is not None + + # 프로젝트 기간에만 팀원 정보 수집 + if team and season.is_project_period(): + for member in team.members.all(): + # 해당 역할의 레벨 조회 + role_level = UserRoleLevel.objects.filter( + user=member.user, + role=member.role + ).first() + + team_members_data.append({ + 'user': member.user, + 'role': member.role, + 'level': role_level.level if role_level else None, + }) + + context = { + "season": season, + "team": team, + "team_members": team_members_data, + "is_matching_period": season.is_matching_period() if season else False, + "team_matched": team_matched, # ✅ 팀 매칭 여부 + } + return render(request, "teams/team.html", context) + + +# ================================ +# API Views (DRF ViewSets) +# ================================ + + +@extend_schema_view( + list=extend_schema(summary="팀 목록 조회", tags=["Teams"]), + retrieve=extend_schema(summary="팀 상세 조회", tags=["Teams"]), + create=extend_schema(summary="팀 생성", tags=["Teams"]), + update=extend_schema(summary="팀 전체 수정", tags=["Teams"]), + partial_update=extend_schema(summary="팀 부분 수정", tags=["Teams"]), + destroy=extend_schema(summary="팀 삭제", tags=["Teams"]), +) +class TeamViewSet(viewsets.ModelViewSet): + """팀 CRUD API""" + queryset = Team.objects.all().prefetch_related('members__user', 'members__role') + + def get_serializer_class(self): + if self.action == 'create': + return TeamCreateSerializer + return TeamSerializer + + +@extend_schema_view( + list=extend_schema(summary="팀 멤버 목록 조회", tags=["Team Members"]), + retrieve=extend_schema(summary="팀 멤버 상세 조회", tags=["Team Members"]), + create=extend_schema(summary="팀 멤버 추가", tags=["Team Members"]), + update=extend_schema(summary="팀 멤버 전체 수정", tags=["Team Members"]), + partial_update=extend_schema(summary="팀 멤버 부분 수정", tags=["Team Members"]), + destroy=extend_schema(summary="팀 멤버 삭제", tags=["Team Members"]), +) +class TeamMemberViewSet(viewsets.ModelViewSet): + """팀 멤버 CRUD API""" + queryset = TeamMember.objects.all().select_related('team', 'user', 'role') + serializer_class = TeamMemberSerializer diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/api_urls.py b/config/api_urls.py new file mode 100644 index 0000000..a7b4874 --- /dev/null +++ b/config/api_urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include + +urlpatterns = [ + path("accounts/", include("apps.accounts.api_urls")), + path("projects/", include("apps.projects.api_urls")), + path("teams/", include("apps.teams.api_urls")), + path("guides/", include("apps.guides.api_urls")), + path("reflections/", include("apps.reflections.api_urls")), +] diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..cd6907c --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..2933c8a --- /dev/null +++ b/config/settings.py @@ -0,0 +1,280 @@ +from pathlib import Path +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# 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 = "django-insecure-)76zvrn)+9frg^h5=7wq!l=xlrqi-57@#7yjq3f(s14$$khudk" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['kitup.duckdns.org', '3.37.88.175', 'localhost', '127.0.0.1',] + +CSRF_TRUSTED_ORIGINS = [ + 'https://kitup.duckdns.org', +] + +if os.getenv("ENV", "dev") == "prod": + DEBUG = False + + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + USE_X_FORWARDED_HOST = True + ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" + + CSRF_TRUSTED_ORIGINS = [ + "https://kitup.duckdns.org", + ] + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + # Third-party apps + "rest_framework", + "drf_spectacular", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.google", + "allauth.socialaccount.providers.kakao", + "allauth.socialaccount.providers.naver", + "allauth.socialaccount.providers.github", + + # Local apps + "apps.accounts.apps.AccountsConfig", + "apps.projects.apps.ProjectsConfig", + "apps.teams.apps.TeamsConfig", + "apps.guides.apps.GuidesConfig", + "apps.reflections.apps.ReflectionsConfig", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + + "allauth.account.middleware.AccountMiddleware", + "apps.accounts.middleware.RequireProfileMiddleware", + + "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" + +# AllAuth settings +SITE_ID = 1 +AUTH_USER_MODEL = "accounts.User" + +# allauth 설정 +ACCOUNT_LOGIN_METHODS = {"username", "email"} # 아이디 또는 이메일로 로그인 +ACCOUNT_EMAIL_REQUIRED = True # 이메일 필수 +ACCOUNT_EMAIL_VERIFICATION = "mandatory" # 이메일 인증 필수 +ACCOUNT_SIGNUP_FIELDS = [ + "username*", + "email*", + "password1*", + "password2*", +] +ACCOUNT_CONFIRM_EMAIL_ON_GET = True # 이메일 링크 클릭만으로 인증 완료 +ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 3 # 인증 링크 유효기간 (일) +ACCOUNT_EMAIL_SUBJECT_PREFIX = "[KITUP] " # 이메일 제목 접두사 + +# 이메일 발송 설정 +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com") +EMAIL_PORT = int(os.getenv("EMAIL_PORT", 587)) +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "True") == "True" +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", '"KITUP" ') + +# 비밀번호 재설정 +ACCOUNT_PASSWORD_RESET_ON_CHANGE = False # 비밀번호 변경 시 재로그인 불필요 +PASSWORD_RESET_TIMEOUT = 86400 # 비밀번호 초기화 토큰 유효시간 (초 단위, 24시간) + +LOGIN_URL = "/accounts/login/" +LOGIN_REDIRECT_URL = "/" # 로그인 성공 후 +LOGOUT_REDIRECT_URL = "/" # 로그아웃 후 + +ACCOUNT_SIGNUP_REDIRECT_URL = "/" # 회원가입 완료 후(가능한 버전에서 동작) +SOCIALACCOUNT_LOGIN_ON_GET = True +ACCOUNT_LOGOUT_ON_GET = True + +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") + +SOCIALACCOUNT_PROVIDERS = { + "google": { + "APPS": [ + { + "client_id": GOOGLE_CLIENT_ID, + "secret": GOOGLE_CLIENT_SECRET, + "key": "", + } + ], + "SCOPE": ["profile", "email"], + "AUTH_PARAMS": {"access_type": "online"}, + "OAUTH_PKCE_ENABLED": True, + }, + "kakao": { + "APPS": [ + { + "client_id": os.getenv("KAKAO_CLIENT_ID"), + "secret": os.getenv("KAKAO_CLIENT_SECRET"), + "key": "", + } + ] + }, + "naver": { + "APPS": [ + { + "client_id": os.getenv("NAVER_CLIENT_ID"), + "secret": os.getenv("NAVER_CLIENT_SECRET"), + "key": "", + } + ], + }, + "github": { + "APPS": [ + { + "client_id": os.getenv("GITHUB_CLIENT_ID"), + "secret": os.getenv("GITHUB_CLIENT_SECRET"), + "key": "", + } + ], + "SCOPE": ["user:email"], + }, +} + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": os.getenv("DB_ENGINE"), + "NAME": os.getenv("DB_NAME"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST"), + "PORT": os.getenv("DB_PORT"), + } +} + + +# 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 = "ko" + +TIME_ZONE = "Asia/Seoul" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] + +# WhiteNoise configuration for serving static files in production +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +# Media files +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +# Django REST Framework 설정 +# https://www.django-rest-framework.org/ + +REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 10, + # 개발 중에는 인증 없이 API 테스트 가능하도록 설정 + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", + ], +} + +# drf-spectacular 설정 (Swagger/OpenAPI) +SPECTACULAR_SETTINGS = { + "TITLE": "StartLine Dev API", + "DESCRIPTION": "StartLine Dev 프로젝트 API 문서", + "VERSION": "1.0.0", + "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], + "SERVERS": [ + {"url": "http://localhost:8000", "description": "Development"}, + {"url": "http://127.0.0.1:8000", "description": "local"}, + ], +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..b492333 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,45 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.views.generic import TemplateView +from django.views.generic import RedirectView +from .views import main_view +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + +urlpatterns = [ + + path("", main_view, name="main"), # 메인 화면 (main.html) + path("admin/", admin.site.urls), + path('privacy-policy/', + TemplateView.as_view(template_name='privacy-policy.html'), + name='privacy_policy'), + + # allauth (로그인/소셜로그인) + path("accounts/", include("allauth.urls")), + # allauth 쪽으로 리다이렉트 + path("login/", RedirectView.as_view(url="/accounts/login/")), + path("logout/", RedirectView.as_view(url="/accounts/logout/")), + path("signup/", RedirectView.as_view(url="/accounts/signup/")), + + # template views: HTML로 보여줄 주소들 + path("accounts/", include("apps.accounts.urls")), + path("projects/", include("apps.projects.urls")), + path("teams/", include("apps.teams.urls")), + path("guides/", include("apps.guides.urls")), + path("reflections/", include("apps.reflections.urls")), + + # API views: Swagger로 테스트할 주소들 + path("api/", include("config.api_urls")), + + # Swagger & API Schema + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), +] + +if settings.DEBUG: + urlpatterns += static( + settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT, + ) \ No newline at end of file diff --git a/config/views.py b/config/views.py new file mode 100644 index 0000000..2d1bc1e --- /dev/null +++ b/config/views.py @@ -0,0 +1,68 @@ +from django.shortcuts import render +from datetime import date + +from apps.accounts.models import UserRoleLevel +from apps.projects.models import Project, Season +from apps.reflections.models import Retrospective + +# 메인 화면 (main.html) +def main_view(request): + """ + 메인 화면 (main.html) + - 비로그인: request.user가 AnonymousUser이므로 템플릿에서 자동 분기 + - 로그인: + 1. 오늘의 작업 기록 (회고) + 2. 팀 매칭 모집 (현재 시즌 프로젝트들) + 3. KITUP 프로젝트 (보관된 프로젝트들) + """ + + user = request.user + season = Season.get_active_season() + context = { + 'season': season, + 'user_obj': user, + } + + # 로그인 상태만 추가 데이터 조회 + if user.is_authenticated: + """회고 부분""" + recent_reflections = Retrospective.objects.filter( + user=user + ).order_by('-created_at')[:4] + + context["recent_reflections"] = recent_reflections + """팀 매칭 부분""" + season = Season.get_active_season() + is_matching_period = season and season.is_matching_period() if season else False + + # 유저의 역할별 레벨 + role_levels = ( + UserRoleLevel.objects + .filter(user=user) + .select_related("role") + ) + + role_level_map = { + rl.role.code: rl.level + for rl in role_levels + } + + # 현재 시즌의 모집 중인 프로젝트들 + matching_projects = None + if season and is_matching_period: + matching_projects = Project.objects.filter( + status__in=[Project.Status.OPEN, Project.Status.MATCHED] + ).select_related('team').order_by('-created_at')[:6] + + context["is_matching_period"] = is_matching_period + context["role_levels"] = role_level_map + context["matching_projects"] = matching_projects + + """KITUP 프로젝트 부분""" + archived_projects = Project.objects.filter( + status=Project.Status.ARCHIVED + ).select_related('team').order_by('-created_at') + + context["archived_projects"] = archived_projects + + return render(request, "main.html", context) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..27c0377 --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/create_test_data.py b/create_test_data.py new file mode 100644 index 0000000..6e0caff --- /dev/null +++ b/create_test_data.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python +""" +테스트 데이터 생성 스크립트 +팀매칭 알고리즘 테스트용 데이터 자동 생성 +""" +import os +import django +from django.utils import timezone +from datetime import timedelta + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from apps.accounts.models import User, Role, UserRoleLevel +from apps.projects.models import Season + +def create_test_data(): + print("=" * 50) + print("🚀 테스트 데이터 생성 시작") + print("=" * 50) + + # 1. Role 확인/생성 + print("\n1️⃣ Role 생성 중...") + roles = {} + role_data = [ + ('PM', 'PM(기획)'), + ('FRONTEND', '프론트엔드'), + ('BACKEND', '백엔드'), + ] + + for code, name in role_data: + role, created = Role.objects.get_or_create( + code=code, + defaults={'name': name} + ) + roles[code] = role + status = "생성" if created else "기존" + print(f" ✅ {status}: {name}") + + # 2. 시즌 생성 + print("\n2️⃣ 시즌 생성 중...") + now = timezone.now() + season, created = Season.objects.get_or_create( + name='2026년 2월 시즌', + defaults={ + 'status': 'MATCHING', + 'matching_start': now - timedelta(hours=1), + 'matching_end': now + timedelta(days=7), + 'project_start': now + timedelta(days=8), + 'project_end': now + timedelta(days=30), + 'is_active': True + } + ) + status = "생성" if created else "기존" + print(f" ✅ {status}: {season.name}") + + # 3. 테스트 유저 생성 (30명: PM 6, FE 12, BE 12) + print("\n3️⃣ 테스트 유저 생성 중...") + test_users = [ + # PM (6명) + ('testpm1', '기획1', 'testpm1@example.com', 'PM', 1, 1), + ('testpm2', '기획2', 'testpm2@example.com', 'PM', 2, 2), + ('testpm3', '기획3', 'testpm3@example.com', 'PM', 2, 4), + ('testpm4', '기획4', 'testpm4@example.com', 'PM', 3, 3), + ('testpm5', '기획5', 'testpm5@example.com', 'PM', 3, 1), + ('testpm6', '기획6', 'testpm6@example.com', 'PM', 4, 2), + + # FE (12명) + ('testfe1', '프론트1', 'testfe1@example.com', 'FRONTEND', 1, 2), + ('testfe2', '프론트2', 'testfe2@example.com', 'FRONTEND', 1, 3), + ('testfe3', '프론트3', 'testfe3@example.com', 'FRONTEND', 1, 4), + ('testfe4', '프론트4', 'testfe4@example.com', 'FRONTEND', 2, 1), + ('testfe5', '프론트5', 'testfe5@example.com', 'FRONTEND', 2, 3), + ('testfe6', '프론트6', 'testfe6@example.com', 'FRONTEND', 2, 4), + ('testfe7', '프론트7', 'testfe7@example.com', 'FRONTEND', 3, 2), + ('testfe8', '프론트8', 'testfe8@example.com', 'FRONTEND', 3, 3), + ('testfe9', '프론트9', 'testfe9@example.com', 'FRONTEND', 3, 1), + ('testfe10', '프론트10', 'testfe10@example.com', 'FRONTEND', 4, 4), + ('testfe11', '프론트11', 'testfe11@example.com', 'FRONTEND', 4, 2), + ('testfe12', '프론트12', 'testfe12@example.com', 'FRONTEND', 4, 1), + + # BE (12명) + ('testbe1', '백엔드1', 'testbe1@example.com', 'BACKEND', 1, 1), + ('testbe2', '백엔드2', 'testbe2@example.com', 'BACKEND', 1, 3), + ('testbe3', '백엔드3', 'testbe3@example.com', 'BACKEND', 1, 4), + ('testbe4', '백엔드4', 'testbe4@example.com', 'BACKEND', 2, 2), + ('testbe5', '백엔드5', 'testbe5@example.com', 'BACKEND', 2, 1), + ('testbe6', '백엔드6', 'testbe6@example.com', 'BACKEND', 2, 3), + ('testbe7', '백엔드7', 'testbe7@example.com', 'BACKEND', 3, 4), + ('testbe8', '백엔드8', 'testbe8@example.com', 'BACKEND', 3, 1), + ('testbe9', '백엔드9', 'testbe9@example.com', 'BACKEND', 3, 2), + ('testbe10', '백엔드10', 'testbe10@example.com', 'BACKEND', 4, 3), + ('testbe11', '백엔드11', 'testbe11@example.com', 'BACKEND', 4, 4), + ('testbe12', '백엔드12', 'testbe12@example.com', 'BACKEND', 4, 2), + ] + + for username, nickname, email, role_code, level, passion_level in test_users: + # 유저 생성 + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': email, + 'nickname': nickname, + 'passion_level': passion_level, + 'is_active': True, + } + ) + + if created: + user.set_password('test1234') + user.save() + else: + # 기존 유저의 열정 레벨 업데이트 + user.passion_level = passion_level + user.save() + + # UserRoleLevel 설정 + role = roles[role_code] + level_obj, _ = UserRoleLevel.objects.update_or_create( + user=user, + role=role, + defaults={'level': level} + ) + + status = "생성" if created else "기존" + print(f" ✅ {status}: {username:10s} ({nickname:6s}) - {role.name:8s} Lv{level} | 열정 {passion_level}⭐") + + print("\n" + "=" * 50) + print("🎉 테스트 데이터 준비 완료!") + print("=" * 50) + print(f"\n📊 생성 통계:") + print(f" • Role: 3개") + print(f" • Season: 1개 (2026년 2월 시즌)") + print(f" • Users: {len(test_users)}명") + print(f" - PM: 6명") + print(f" - FE: 12명") + print(f" - BE: 12명") + print(f"\n💡 로그인 정보:") + print(f" • Username: testpm1, testfe1, testbe1 등") + print(f" • Password: test1234") + print(f"\n🎯 다음 단계:") + print(f" 1. 로그인하여 마이페이지에서 레벨 확인") + print(f" 2. 관리자 페이지에서 팀매칭 실행") + print(f" 3. 결과 확인") + +if __name__ == '__main__': + create_test_data() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3df594f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +version: "3.8" + +services: + db: + image: postgres:15-alpine + container_name: kitup_db + env_file: + - .env + environment: + - POSTGRES_DB=${POSTGRES_DB:-kitup_db} + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-your_password} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + web: + image: ghcr.io/pirogramming/startlinedev/web:latest + container_name: kitup_web + env_file: + - .env + command: > + sh -c " + python manage.py migrate && + python manage.py collectstatic --noinput && + python manage.py runserver 0.0.0.0:8000 + " + volumes: + # - .:/app + - static_volume:/app/staticfiles + - ./media:/app/media + ports: + - "8000:8000" + environment: + DB_ENGINE: django.db.backends.postgresql + DB_NAME: ${POSTGRES_DB:-kitup_db} + DB_USER: ${POSTGRES_USER:-postgres} + DB_PASSWORD: ${POSTGRES_PASSWORD:-your_password} + DB_HOST: db + DB_PORT: 5432 + REDIS_HOST: redis + REDIS_PORT: 6379 + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + + redis: + image: redis:7-alpine + container_name: kitup_redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + postgres_data: + static_volume: + redis_data: \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..d28672e --- /dev/null +++ b/manage.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f4d9592 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,33 @@ +asgiref==3.11.0 +attrs==25.4.0 +bleach==6.3.0 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +whitenoise==6.8.2 +cryptography==46.0.4 +Django==5.2.10 +django-allauth==65.14.0 +djangorestframework==3.14.0 +drf-spectacular==0.27.0 +idna==3.11 +inflection==0.5.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +Markdown==3.10.1 +pillow==12.1.0 +psycopg2-binary==2.9.10 +pycparser==3.0 +PyJWT==2.10.1 +python-dotenv==1.0.0 +pytz==2025.2 +PyYAML==6.0.3 +referencing==0.37.0 +requests==2.32.5 +rpds-py==0.30.0 +sqlparse==0.5.5 +typing_extensions==4.15.0 +tzdata==2025.3 +uritemplate==4.2.0 +urllib3==2.6.3 +webencodings==0.5.1 diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..06f26a0 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,360 @@ +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css'); + +body { + font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif; + margin: 0; padding: 0; +} + +* { + margin: 0; padding: 0; + box-sizing: border-box; +} + +header { + position: fixed; + top: 0; left: 0; + background: #fff; + z-index: 1000; + width: 100%; height: 60px; + display: flex; + align-items: center; + padding: 0 50px; + justify-content: space-between; +} + +header a { + text-decoration: none; + color: black; +} + +header a h3 { + color: #4272EF; + font-size: 32px; + font-weight: 800; +} + +header .header_menu { + display: flex; +} + +header .header_menu > a { + display: block; + color: #4272EF; + width: 110px; height: 40px; + margin-left: 20px; text-align: center; + border-radius: 20px; +} + +header .header_menu > a:hover { + background: #4272EF; + color: #fff; + transition: 0.3s ease; +} + +header .header_menu > a p { + padding: 13px auto; + font-size: 14px; font-weight: bold; + line-height: 40px; +} + +main { + padding-top: 60px; + min-height: calc(100vh - 60px); +} + +.dropdown { + position: relative; + display: inline-block; + margin-left: 20px; +} + +.dropdown > a { + display: block; + color: #4272EF; + width: 110px; height: 40px; + text-align: center; + border-radius: 20px; +} + +.dropdown > a p { + padding: 13px auto; + font-size: 14px; font-weight: bold; + line-height: 40px; +} + +.dropdown-content { + display: none; + position: absolute; + top: 42px; + left: -10px; + background: #fff; + min-width: 130px; + overflow: hidden; + z-index: 1001; + padding-top: 3px; + border-bottom-left-radius: 15px; + border-bottom-right-radius: 15px; +} + +.dropdown-content::before { + content: ''; + position: absolute; + top: -3px; + left: 0; + right: 0; + height: 3px; + background: transparent; +} + +.dropdown-content a { + display: block; + color: #4272EF; + padding: 12px 0; + text-decoration: none; + font-size: 14px; + font-weight: 600; + text-align: center; +} + +.dropdown-content a:hover { + background: #4272EF; + color: #fff; + transition: 0.5s ease; +} + +.dropdown:hover .dropdown-content { + display: block; +} + +/* ======================================== + 반응형 미디어 쿼리 +======================================== */ + +/* 햄버거 메뉴 버튼 (기본 숨김) */ +.hamburger { + display: none; + flex-direction: column; + cursor: pointer; + padding: 5px; + z-index: 1002; +} + +.hamburger span { + width: 25px; + height: 3px; + background: #4272EF; + margin: 3px 0; + transition: 0.3s; + border-radius: 3px; +} + +/* 햄버거 애니메이션 */ +.hamburger.active span:nth-child(1) { + transform: rotate(-45deg) translate(-5px, 6px); +} + +.hamburger.active span:nth-child(2) { + opacity: 0; +} + +.hamburger.active span:nth-child(3) { + transform: rotate(45deg) translate(-5px, -6px); +} + +/* 모바일 오버레이 */ +.mobile-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 998; +} + +.mobile-overlay.active { + display: block; +} + +/* 데스크탑 & 노트북 (769px 이상) */ +@media (min-width: 884px) { + header { + padding: 0 50px; + } + + header a h3 { + font-size: 32px; + } + + .header_menu { + display: flex !important; + } + + .dropdown > a:hover { + background: #4272EF; + color: #fff; + transition: 0.5s ease; + } + + .dropdown-content { + display: none !important; + } +} + +/* 태블릿 & 모바일 (768px 이하) */ +@media (max-width: 883px) { + header { + padding: 0 20px; + } + + header a h3 { + font-size: 28px; + } + + /* 햄버거 버튼 표시 */ + .hamburger { + display: flex; + } + + /* 메뉴 기본 숨김 */ + .header_menu { + display: none; + position: fixed; + top: 60px; + right: -250px; + width: 250px; + height: calc(100vh - 60px); + background: #fff; + flex-direction: column; + padding: 20px 0; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); + z-index: 999; + overflow-y: auto; + transition: right 0.3s ease-out; + } + + .header_menu.active { + display: flex; + right: 0; + } + + /* 메뉴 아이템 */ + header .header_menu > a { + width: 100%; + margin: 0; + height: 50px; + border-radius: 0; + border-bottom: 1px solid #f0f0f0; + text-align: center; + } + + header .header_menu > a p { + line-height: 50px; + font-size: 15px; + } + + header .header_menu > a:hover { + background: #4272EF; + color: #fff; + } + + /* 드롭다운 */ + .header_menu .dropdown { + width: 100%; + margin: 0; + } + + .dropdown > a { + width: 100%; + height: 50px; + border-radius: 0; + border-bottom: 1px solid #f0f0f0; + text-align: center; + color: #4272EF; + } + + .dropdown > a p { + line-height: 50px; + font-size: 15px; + } + + .dropdown-content { + position: static; + display: none; + min-width: 100%; + border-radius: 0; + background: #fff; + padding-top: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + } + + .dropdown-content::before { + display: none; + } + + .dropdown.active .dropdown-content { + display: block; + max-height: 120px; + } + + .dropdown-content a { + display: block; + width: 100%; + padding: 15px 0; + height: auto; + border-bottom: 1px solid #f0f0f0; + text-align: center; + background: #fff; + color: #4272EF; + font-size: 14px; + font-weight: 600; + } + + .dropdown-content a:last-child { + border-bottom: none; + } + + .dropdown-content a:hover { + background: #4272EF; + color: #fff; + } +} + +/* 모바일 (480px 이하) */ +@media (max-width: 480px) { + header { + padding: 0 15px; + } + + header a h3 { + font-size: 24px; + } + + .hamburger span { + width: 22px; + height: 2.5px; + } + + .header_menu { + width: 220px; + } + + header .header_menu > a, + .dropdown > a { + height: 48px; + } + + header .header_menu > a p, + .dropdown > a p { + line-height: 48px; + font-size: 14px; + } + + .dropdown-content a { + padding: 12px 0; + font-size: 13px; + } +} \ No newline at end of file diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..aa49a9e --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,1063 @@ +/* 프로젝트 헤더 */ +.p_header { + width: 90%; + height: 50px; + margin: 0 auto; + display: flex; +} + +.p_header a { + display: block; + width: 50%; + text-align: center; + padding: 15px; + text-decoration: none; + background: #F6F8FF; +} + +.p_header .p_dashboard { + background: #F6F8FF; + color: #1d294b; +} + +.p_header .p_mission { + background: #fff; + color: #4272EF; +} + +.p_header .p_mission:hover { + background: #eaf0ff; + color: #1d294b; + transition: 0.3s ease-in-out; +} + +.p_header a p { + font-size: 18px; + font-weight: 700; +} + +.dashboard { + width: 85%; + max-width: 1400px; + margin: 0 auto; + text-align: center; +} + +.no_project { + margin: 0 auto; + text-align: center; +} + +.no_project > h3 { + font-size: 40px; font-weight: 650; + margin: 150px auto 30px; +} + +.no_project > a { + display: block; + text-align: center; + width: 200px; height: 40px; + margin: 0 auto; + text-decoration: none; + background: #4272EF; + border-radius: 20px; +} + +.no_project > a:hover { + background: #1F4CC0; + transition: 0.3s ease; +} + +.no_project > a > p { + color: #fff; + font-size: 20px; font-weight: 500; + line-height: 40px; +} + +/* 프로젝트 개요 */ +.d_service { + margin: 60px auto 0; + text-align: center; +} + +.d_service > img { + max-width: 500px; + width: 100%; +} + +.d_service > h3 { + margin-top: 20px; + font-size: 35px; font-weight: 600; +} + +.d_service > p { + margin-top: 15px; + font-size: 24px; +} + +.project_image { + border-radius: 25px; +} + +/* 진행기간 & 즐겨찾기 */ +.d_period { + display: flex; + margin-top: 70px; + align-items: center; +} + +.d_period > img { + width: 40px; height: 40px; + margin-right: 7px; +} + +.d_period > p { + font-size: 18px; +} + +.d_period > p > .period_title { + font-weight: 600; font-size: 22px; + margin-right: 5px; +} + +.d_bookmark { + text-align: start; +} + +.b_title { + display: flex; + margin-top: 15px; + align-items: center; +} + +.b_title > img { + width: 40px; height: 40px; + margin-right: 7px; +} + +.b_title > p { + font-weight: 600; font-size: 22px; + margin-top: 2px; +} + +.b_content { + margin-top: 5px; + margin-left: 47px; +} + +.b_content > p { + font-size: 18px; +} + +hr { + margin-top: 50px; + border: 2px solid #F1F1F1; +} + +/* 컨텐츠 */ +.d_team { + text-align: start; + margin-top: 50px; +} + +.t_title { + display: flex; + align-items: center; + position: relative; +} + +.t_title > img { + width: 40px; height: 40px; + margin-right: 7px; +} + +.t_title > h3 { + font-weight: 600; font-size: 22px; + margin-top: 2px; +} + +.t_title > .t_report { + position: absolute; + top: 0; right: 0; + display: flex; + align-items: center; +} + +.t_report > img { + width: 20px; height: 20px; + margin-right: 7px; +} + +.t_report a { + text-decoration: none; + font-size: 14px; + color: #FF0000; +} + +.t_content { + margin-top: 5px; +} + +.d_team > .t_content > p { + margin-left: 47px; + font-size: 18px; +} + +.d_team > .t_content > p > .t_role { + font-weight: 600; font-size: 19px; + margin-right: 5px; +} + +/* 팀원 카드 컨테이너 - 가로 스크롤 */ +.t_member { + width: 90%; + margin: 20px auto 0; + padding: 20px 0; + display: flex; + gap: 15px; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; +} + +.t_member::-webkit-scrollbar { + height: 8px; +} + +/* 팀원 카드 */ +.t_member > .member { + position: relative; + background: #fff; + box-shadow: 1px 2px 2px 1px #ccc; + padding: 15px 25px; + max-width: 200px; + height: 300px; + text-align: center; + border-radius: 20px; + flex-shrink: 0; +} + +.member > .profile_section { + position: relative; + width: 100%; + height: 100px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 15px; +} + +.member > .profile_section > .profile { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; +} + +.member > .profile_section > .level { + width: 35px; + height: 35px; + position: absolute; + bottom: 0; + right: calc(50% - 55px); +} + +.member > h3 { + margin-top: 15px; + margin-bottom: 15px; + font-size: 20px; + font-weight: 550; +} + +.member > .info { + text-align: start; + font-size: 14px; + word-break: break-all; + overflow-wrap: break-word; +} + +.member > .role_design { + position: absolute; + bottom: 15px; right: 50px; + font-size: 16px; + margin: 15px auto; + padding: 5px 0; + width: 50%; + height: 30px; + border: 1px solid #00B9B0; + border-radius: 20px; + color: #00B9B0; + box-shadow: 1px 2px 2px 1px #00B9B0; +} + +.member > .role_frontend { + position: absolute; + bottom: 15px; right: 40px; + font-size: 16px; + margin: 15px auto; + padding: 5px 0; + width: 60%; + height: 30px; + border: 1px solid #FFCE53; + border-radius: 20px; + color: #FFCE53; + box-shadow: 1px 2px 2px 1px #FFCE53; +} + +.member > .role_backend { + position: absolute; + bottom: 15px; right: 50px; + font-size: 16px; + margin: 15px auto; + padding: 5px 0; + width: 50%; + height: 30px; + border: 1px solid #FF3E88; + border-radius: 20px; + color: #FF3E88; + box-shadow: 1px 2px 2px 1px #FF3E88; +} + +.d_rule { + margin-top: 50px; + text-align: start; +} + +.r_title { + display: flex; + align-items: center; + position: relative; +} + +.r_title > img { + width: 40px; + height: 40px; + margin-right: 7px; +} + +.r_title > h3 { + font-weight: 600; + font-size: 22px; + margin-top: 2px; +} + +.r_content { + margin-left: 47px; + margin-top: 5px; +} + +.r_content > p { + font-size: 18px; + line-height: 22px; +} + +.d_link { + margin-top: 30px; + text-align: start; +} + +.l_title { + display: flex; + align-items: center; + position: relative; +} + +.l_title > img { + width: 40px; + height: 40px; + margin-right: 7px; +} + +.l_title > h3 { + font-weight: 600; + font-size: 22px; + margin-top: 2px; +} + +.l_content { + margin-left: 47px; + margin-top: 5px; + font-size: 18px; +} + +.l_content > p > a { + text-decoration: none; + color: #000; + font-size: 18px; + line-height: 22px; +} + +.l_content > p > a:hover { + color: #4272EF; + transition: 0.3s ease; +} +.d_progress { + margin-top: 30px; + text-align: start; + margin-bottom: 40px; +} + +.p_title { + display: flex; + align-items: center; + position: relative; + margin-bottom: 10px; +} + +.p_title > img { + width: 50px; + height: 50px; + margin-right: 10px; +} + +.p_title > div > h3 { + font-weight: 600; + font-size: 22px; + margin: 0; +} + +.role_progress_bar { + margin: 10px 0 15px 0; + display: flex; + align-items: center; + gap: 15px; +} + +.role_progress_bar > h3 { + width: 40px; + font-size: 18px; + font-weight: 550; + margin: 0; +} + +.progress_bar_container { + position: relative; + flex: 1; + height: 15px; + background: #DDDDDD; + border-radius: 20px; + overflow: hidden; +} + +.progress_bar_fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: #5A88FF; + border-radius: 20px; + transition: width 0.5s ease; +} + +.progress_percent { + min-width: 45px; + font-size: 14px; + font-weight: 600; + color: #4272EF; +} + + +button { + width: 200px; + height: 40px; + background: #4272EF; + border: none; + border-radius: 20px; + color: #fff; + font-size: 18px; + font-weight: 500; + text-align: center; + margin-bottom: 200px; +} + +button:hover { + cursor: pointer; + background: #1F4CC0; + transition: 0.3s ease; +} + +/* 모달 배경 */ +.modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; + width: 100vw; height: 100vh; + background: rgba(0, 0, 0, 0.5); + z-index: 9999; + display: none; + justify-content: center; + align-items: center; +} + +/* 팝업 박스 */ +.modal-content { + background: white; + width: 450px; height: 70%; + padding: 30px; + border-radius: 15px; + box-shadow: 0 5px 20px rgba(0,0,0,0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-header h3 { font-size: 20px; font-weight: 700; color: #1d294b; } + +.close-btn { cursor: pointer; font-size: 15px; color: #aaa; } + +/* 멤버 리스트 */ +.member-select-list { + margin: 15px 0; + max-height: 180px; + overflow-y: auto; +} + +.member-option { + display: flex; + align-items: center; + padding: 12px; + border: 1px solid #eee; + border-radius: 10px; + margin-bottom: 10px; + cursor: pointer; +} + +.member-option:hover { background: #f6f8ff; } + +.member-info { + display: flex; align-items: center; gap: 10px; + margin-left: 10px; +} + +.member-info img { + width: 35px; height: 35px; + border-radius: 50%; + object-fit: cover; +} + +/* 입력창 */ +textarea { + width: 100%; height: 50px; + margin-top: 10px; + padding: 12px; + border: 1px solid #ddd; + border-radius: 8px; + resize: none; + box-sizing: border-box; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.btn-danger { + background: #4272EF; color: white; border: none; padding: 5px 10px; border-radius: 8px; cursor: pointer; + font-size: 15px; +} +.btn-secondary { background: #f0f0f0; border: none; padding: 5px 10px; border-radius: 8px; cursor: pointer; } + +/* =================================== + 반응형 미디어 쿼리 + =================================== */ + +/* 노트북 (1024px ~ 1400px) */ +@media (max-width: 1400px) { + .dashboard { + width: 90%; + } +} + +/* 태블릿 (768px ~ 1024px) */ +@media (max-width: 1024px) { + .dashboard { + width: 95%; + } + + .p_header a p { + font-size: 16px; + } + + .d_service > img { + max-width: 400px; + } + + .d_service > h3 { + font-size: 28px; + } + + .d_service > p { + font-size: 20px; + } + + .d_period > img { + width: 35px; + height: 35px; + } + + .d_period > p { + font-size: 16px; + } + + .d_period > p > .period_title { + font-size: 20px; + } + + .t_title > img, + .b_title > img, + .r_title > img, + .l_title > img { + width: 35px; + height: 35px; + } + + .t_title > h3, + .b_title > p, + .r_title > h3, + .l_title > h3 { + font-size: 20px; + } + + .p_title > img { + width: 45px; + height: 45px; + } + + .t_member > .member { + max-width: 180px; + height: 280px; + padding: 12px 20px; + } + + .member > .profile_section > .profile { + width: 90px; + height: 90px; + } + + .member > .profile_section > .level { + width: 32px; + height: 32px; + right: calc(50% - 50px); + } + + .member > h3 { + font-size: 18px; + } + + .member > .info { + font-size: 13px; + } + + .r_content > p { + font-size: 16px; + line-height: 20px; + } + + .l_content { + font-size: 16px; + } + + .l_content > p > a { + font-size: 16px; + line-height: 20px; + word-break: break-all; + } + + .modal-content { + width: 400px; + height: 65%; + } +} + +/* 모바일 (~ 768px) */ +@media (max-width: 768px) { + .dashboard { + width: 95%; + } + + .p_header { + width: 95%; + height: 45px; + } + + .p_header a { + padding: 12px; + } + + .p_header a p { + font-size: 14px; + } + + .no_project > h3 { + font-size: 28px; + margin: 100px auto 20px; + } + + .no_project > a { + width: 180px; + height: 35px; + } + + .no_project > a > p { + font-size: 16px; + line-height: 35px; + } + + .d_service { + margin: 40px auto 0; + } + + .d_service > img { + max-width: 300px; + } + + .d_service > h3 { + font-size: 24px; + margin-top: 15px; + } + + .d_service > p { + font-size: 16px; + margin-top: 12px; + padding: 0 10px; + } + + .d_period { + margin-top: 50px; + flex-direction: column; + align-items: flex-start; + } + + .d_period > img { + width: 30px; + height: 30px; + margin-bottom: 5px; + } + + .d_period > p { + font-size: 14px; + margin-left: 0; + } + + .d_period > p > .period_title { + font-size: 16px; + } + + .b_title > img { + width: 30px; + height: 30px; + } + + .b_title > p { + font-size: 18px; + } + + .b_content { + margin-left: 37px; + } + + hr { + margin-top: 35px; + } + + .d_team, + .d_rule, + .d_link, + .d_progress { + margin-top: 35px; + } + + .t_title > img, + .r_title > img, + .l_title > img { + width: 30px; + height: 30px; + } + + .t_title > h3, + .r_title > h3, + .l_title > h3 { + font-size: 18px; + } + + .p_title > img { + width: 40px; + height: 40px; + } + + .p_title > h3 { + font-size: 18px; + } + + .t_report > img { + width: 18px; + height: 18px; + } + + .t_report a { + font-size: 12px; + } + + .d_team > .t_content > p { + margin-left: 37px; + font-size: 16px; + } + + .d_team > .t_content > p > .t_role { + font-size: 17px; + } + + .t_member { + margin-top: 15px; + padding: 15px 0; + gap: 12px; + } + + .t_member > .member { + max-width: 160px; + height: 260px; + padding: 10px 18px; + } + + .member > .profile_section { + height: 80px; + margin-bottom: 12px; + } + + .member > .profile_section > .profile { + width: 80px; + height: 80px; + } + + .member > .profile_section > .level { + width: 28px; + height: 28px; + right: calc(50% - 45px); + } + + .member > h3 { + font-size: 16px; + margin-top: 12px; + margin-bottom: 12px; + } + + .member > .info { + font-size: 12px; + } + + .member > .role_design, + .member > .role_frontend, + .member > .role_backend { + font-size: 14px; + height: 28px; + padding: 4px 0; + bottom: 10px; + } + + .member > .role_design { + right: 40px; + } + + .member > .role_frontend { + right: 30px; + } + + .member > .role_backend { + right: 40px; + } + + .r_content { + margin-left: 37px; + } + + .r_content > p { + font-size: 14px; + line-height: 20px; + } + + .l_content { + margin-left: 37px; + font-size: 14px; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .l_content > p { + word-break: break-all; + white-space: pre-wrap; + } + + .l_content > p > a { + font-size: 14px; + line-height: 20px; + word-break: break-all; + display: inline-block; + max-width: 100%; + } + + .p_content { + margin: 5px 0 80px 37px; + } + + .p_bar { + height: 18px; + } + + button { + width: 180px; + height: 38px; + font-size: 16px; + margin-bottom: 150px; + } + + .modal-content { + width: 90%; + max-width: 350px; + height: 60%; + padding: 25px; + } + + .modal-header h3 { + font-size: 18px; + } + + .member-select-list { + max-height: 150px; + } + + .member-option { + padding: 10px; + } + + .member-info img { + width: 30px; + height: 30px; + } + + textarea { + height: 45px; + font-size: 13px; + } +} + +/* 소형 모바일 (~ 480px) */ +@media (max-width: 480px) { + .p_header a p { + font-size: 13px; + } + + .d_service > img { + max-width: 250px; + } + + .d_service > h3 { + font-size: 20px; + } + + .d_service > p { + font-size: 14px; + padding: 0 5px; + } + + .d_period > p { + font-size: 13px; + } + + .d_period > p > .period_title { + font-size: 15px; + } + + .t_member > .member { + max-width: 140px; + height: 240px; + padding: 10px 15px; + } + + .member > .profile_section { + height: 70px; + } + + .member > .profile_section > .profile { + width: 70px; + height: 70px; + } + + .member > .profile_section > .level { + width: 25px; + height: 25px; + right: calc(50% - 40px); + } + + .member > h3 { + font-size: 15px; + } + + .member > .info { + font-size: 11px; + } + + .member > .role_design, + .member > .role_frontend, + .member > .role_backend { + font-size: 13px; + height: 26px; + } + + .r_content { + margin-left: 30px; + } + + .r_content > p { + font-size: 13px; + line-height: 18px; + } + + .l_content { + margin-left: 30px; + font-size: 13px; + padding-right: 5px; + } + + .l_content > p > a { + font-size: 13px; + line-height: 18px; + word-break: break-all; + overflow-wrap: anywhere; + } + + .p_content { + margin-left: 30px; + } + + button { + width: 160px; + height: 35px; + font-size: 15px; + } +} + + +@media (max-width: 360px) { + .l_content { + margin-left: 25px; + font-size: 12px; + } + + .l_content > p > a { + font-size: 12px; + line-height: 16px; + } + + .r_content { + margin-left: 25px; + } + + .r_content > p { + font-size: 12px; + } +} \ No newline at end of file diff --git a/static/css/dashboard_update.css b/static/css/dashboard_update.css new file mode 100644 index 0000000..69c357c --- /dev/null +++ b/static/css/dashboard_update.css @@ -0,0 +1,1043 @@ +/* 프로젝트 헤더 */ +.p_header { + width: 90%; + height: 50px; + margin: 0 auto; + display: flex; +} + +.p_header a { + display: block; + width: 50%; + text-align: center; + padding: 15px; + text-decoration: none; + background: #F6F8FF; +} + +.p_header .p_dashboard { + background: #F6F8FF; + color: #1d294b; +} + +.p_header .p_mission { + background: #fff; + color: #cad9ff; +} + +.p_header .p_mission:hover { + background: #eaf0ff; + color: #1d294b; + transition: 0.3s ease-in-out; +} + +.p_header a p { + font-size: 18px; + font-weight: 700; +} + +.dashboard { + width: 85%; + max-width: 1400px; + margin: 0 auto; + text-align: center; +} + +.no_project { + margin: 0 auto; + text-align: center; +} + +.no_project > h3 { + font-size: 40px; font-weight: 650; + margin: 150px auto 30px; +} + +.no_project > a { + display: block; + text-align: center; + width: 200px; height: 40px; + margin: 0 auto; + text-decoration: none; + background: #4272EF; + border-radius: 20px; +} + +.no_project > a:hover { + background: #1F4CC0; + transition: 0.3s ease; +} + +.no_project > a > p { + color: #fff; + font-size: 20px; font-weight: 500; + line-height: 40px; +} + +/* 프로젝트 개요 */ +.d_service { + margin: 60px auto 0; + text-align: center; +} + +.d_service > label { + display: block; +} + +.d_service > label > img { + max-width: 500px; + width: 100%; +} + +label > .project_image { + border-radius: 25px; +} + +.d_service > input { + display: block; + margin: 0 auto; + width: 30%; height: 40px; + text-align: center; + margin-top: 20px; + font-size: 30px; font-weight: 600; + border: 1px solid #ccc; + border-radius: 10px; +} + +.d_service > input:focus { + border: none; + outline: 1px solid #1F4CC0; +} + +.d_service > textarea { + width: 60%; height: 80px; + margin-top: 15px; + font-size: 16px; +} + +.d_service > textarea:focus { + border: none; + outline: 1px solid #4272EF; +} + +/* 진행기간 & 즐겨찾기 */ +.d_period { + display: flex; + margin-top: 70px; + align-items: center; +} + +.d_period > img { + width: 40px; height: 40px; + margin-right: 7px; +} + +.d_period > p { + font-size: 18px; +} + +.d_period > p > .period_title { + font-weight: 600; font-size: 22px; + margin-right: 5px; +} + +.d_bookmark { + text-align: start; +} + +.b_title { + display: flex; + margin-top: 15px; + align-items: center; +} + +.b_title > img { + width: 40px; height: 40px; + margin-right: 7px; +} + +.b_title > p { + font-weight: 600; font-size: 22px; + margin-top: 2px; +} + +.b_content { + margin-top: 5px; + margin-left: 47px; +} + +.b_content > p { + font-size: 18px; +} + +hr { + margin-top: 50px; + border: 2px solid #F1F1F1; +} + +/* 컨텐츠 */ +.d_team { + text-align: start; + margin-top: 50px; +} + +.t_title { + display: flex; + align-items: center; + position: relative; +} + +.t_title > img { + width: 40px; height: 40px; + margin-right: 7px; +} + +.t_title > h3 { + font-weight: 600; font-size: 22px; + margin-top: 2px; +} + +.t_title > .t_report { + position: absolute; + top: 0; right: 0; + display: flex; + align-items: center; +} + +.t_report > img { + width: 20px; height: 20px; + margin-right: 7px; +} + +.t_report a { + text-decoration: none; + font-size: 14px; + color: #FF0000; +} + +.t_content { + margin-top: 5px; +} + +.d_team > .t_content > p { + margin-left: 47px; + font-size: 18px; +} + +.d_team > .t_content > p > .t_role { + font-weight: 600; font-size: 19px; + margin-right: 5px; +} + +/* 팀원 카드 컨테이너 - 가로 스크롤 */ +.t_member { + width: 90%; + margin: 20px auto 0; + padding: 20px 0; + display: flex; + gap: 15px; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; +} + +.t_member::-webkit-scrollbar { + height: 8px; +} + +/* 팀원 카드 */ +.t_member > .member { + position: relative; + background: #fff; + box-shadow: 1px 2px 2px 1px #ccc; + padding: 15px 25px; + max-width: 200px; + height: 300px; + text-align: center; + border-radius: 20px; + flex-shrink: 0; +} + +.member > .profile_section { + position: relative; + width: 100%; + height: 100px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 15px; +} + +.member > .profile_section > .profile { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; +} + +.member > .profile_section > .level { + width: 35px; + height: 35px; + position: absolute; + bottom: 0; + right: calc(50% - 55px); +} + +.member > h3 { + margin-top: 15px; + margin-bottom: 15px; + font-size: 20px; + font-weight: 550; +} + +.member > .info { + text-align: start; + font-size: 14px; + word-break: break-all; + overflow-wrap: break-word; +} + +.member > .role_design { + position: absolute; + bottom: 15px; right: 50px; + font-size: 16px; + margin: 15px auto; + padding: 5px 0; + width: 50%; + height: 30px; + border: 1px solid #00B9B0; + border-radius: 20px; + color: #00B9B0; + box-shadow: 1px 2px 2px 1px #00B9B0; +} + +.member > .role_frontend { + position: absolute; + bottom: 15px; right: 40px; + font-size: 16px; + margin: 15px auto; + padding: 5px 0; + width: 60%; + height: 30px; + border: 1px solid #FFCE53; + border-radius: 20px; + color: #FFCE53; + box-shadow: 1px 2px 2px 1px #FFCE53; +} + +.member > .role_backend { + position: absolute; + bottom: 15px; right: 50px; + font-size: 16px; + margin: 15px auto; + padding: 5px 0; + width: 50%; + height: 30px; + border: 1px solid #FF3E88; + border-radius: 20px; + color: #FF3E88; + box-shadow: 1px 2px 2px 1px #FF3E88; +} + +.d_rule { + margin-top: 50px; + text-align: start; +} + +.r_title { + display: flex; + align-items: center; + position: relative; +} + +.r_title > img { + width: 40px; + height: 40px; + margin-right: 7px; +} + +.r_title > h3 { + font-weight: 600; + font-size: 22px; + margin-top: 2px; +} + +.r_content { + margin-left: 47px; + margin-top: 5px; +} + +.r_content > textarea { + width: 60%; height: 120px; + font-size: 14px; + line-height: 22px; + border: 1px solid #ccc; +} + +.r_content > textarea:focus { + border: none; + outline: 1px solid #4272EF; +} + +.d_link { + margin-top: 30px; + text-align: start; +} + +.l_title { + display: flex; + align-items: center; + position: relative; +} + +.l_title > img { + width: 40px; + height: 40px; + margin-right: 7px; +} + +.l_title > h3 { + font-weight: 600; + font-size: 22px; + margin-top: 2px; +} + +.l_content { + margin-left: 47px; + margin-top: 5px; +} + +.l_content > textarea { + width: 60%; height: 120px; + font-size: 14px; + line-height: 22px; + border: 1px solid #ccc; +} + +.l_content > textarea:focus { + border: none; + outline: 1px solid #4272EF; +} + + +.d_progress { + margin-top: 30px; + text-align: start; + margin-bottom: 40px; +} + +.p_title { + display: flex; + align-items: center; + position: relative; + margin-bottom: 10px; +} + +.p_title > img { + width: 50px; + height: 50px; + margin-right: 10px; +} + +.p_title > div > h3 { + font-weight: 600; + font-size: 22px; + margin: 0; +} + +.role_progress_bar { + margin: 10px 0 15px 0; + display: flex; + align-items: center; + gap: 15px; +} + +.role_progress_bar > h3 { + width: 40px; + font-size: 18px; + font-weight: 550; + margin: 0; +} + +.progress_bar_container { + position: relative; + flex: 1; + height: 15px; + background: #DDDDDD; + border-radius: 20px; + overflow: hidden; +} + +.progress_bar_fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: #5A88FF; + border-radius: 20px; + transition: width 0.5s ease; +} + +.progress_percent { + min-width: 45px; + font-size: 14px; + font-weight: 600; + color: #4272EF; +} + +button { + width: 200px; + height: 40px; + background: #4272EF; + border: none; + border-radius: 20px; + color: #fff; + font-size: 18px; + font-weight: 500; + text-align: center; + margin-bottom: 200px; +} + +button:hover { + cursor: pointer; + background: #1F4CC0; + transition: 0.3s ease; +} + +/* 모달 배경 */ +.modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; + width: 100vw; height: 100vh; + background: rgba(0, 0, 0, 0.5); + z-index: 9999; + display: none; + justify-content: center; + align-items: center; +} + +/* 팝업 박스 */ +.modal-content { + background: white; + width: 450px; height: 70%; + padding: 30px; + border-radius: 15px; + box-shadow: 0 5px 20px rgba(0,0,0,0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-header h3 { font-size: 20px; font-weight: 700; color: #1d294b; } + +.close-btn { cursor: pointer; font-size: 15px; color: #aaa; } + +/* 멤버 리스트 */ +.member-select-list { + margin: 15px 0; + max-height: 180px; + overflow-y: auto; +} + +.member-option { + display: flex; + align-items: center; + padding: 12px; + border: 1px solid #eee; + border-radius: 10px; + margin-bottom: 10px; + cursor: pointer; +} + +.member-option:hover { background: #f6f8ff; } + +.member-info { + display: flex; align-items: center; gap: 10px; + margin-left: 10px; +} + +.member-info img { + width: 35px; height: 35px; + border-radius: 50%; + object-fit: cover; +} + +/* 입력창 */ +textarea { + width: 100%; height: 50px; + margin-top: 10px; + padding: 12px; + border: 1px solid #ddd; + border-radius: 8px; + resize: none; + box-sizing: border-box; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.btn-danger { + background: #4272EF; color: white; border: none; padding: 5px 10px; border-radius: 8px; cursor: pointer; + font-size: 15px; +} +.btn-secondary { background: #f0f0f0; border: none; padding: 5px 10px; border-radius: 8px; cursor: pointer; } + +/* =================================== + 반응형 미디어 쿼리 + =================================== */ + +/* 노트북 (1024px ~ 1400px) */ +@media (max-width: 1400px) { + .dashboard { + width: 90%; + } + + .d_service > input { + width: 40%; + } + + .d_service > textarea { + width: 70%; + } + + .r_content > textarea, + .l_content > textarea { + width: 70%; + } +} + +/* 태블릿 (768px ~ 1024px) */ +@media (max-width: 1024px) { + .dashboard { + width: 95%; + } + + .p_header a p { + font-size: 16px; + } + + .d_service > img { + max-width: 400px; + } + + .d_service > input { + width: 50%; + font-size: 24px; + height: 35px; + } + + .d_service > textarea { + width: 80%; + height: 70px; + font-size: 15px; + } + + .d_period > img { + width: 35px; + height: 35px; + } + + .d_period > p { + font-size: 16px; + } + + .d_period > p > .period_title { + font-size: 20px; + } + + .t_title > img, + .b_title > img, + .r_title > img, + .l_title > img { + width: 35px; + height: 35px; + } + + .t_title > h3, + .b_title > p, + .r_title > h3, + .l_title > h3 { + font-size: 20px; + } + + .p_title > img { + width: 45px; + height: 45px; + } + + .t_member > .member { + max-width: 180px; + height: 280px; + padding: 12px 20px; + } + + .member > .profile_section > .profile { + width: 90px; + height: 90px; + } + + .member > .profile_section > .level { + width: 32px; + height: 32px; + right: calc(50% - 50px); + } + + .member > h3 { + font-size: 18px; + } + + .member > .info { + font-size: 13px; + } + + .r_content > textarea, + .l_content > textarea { + width: 80%; + height: 100px; + font-size: 13px; + } + + .modal-content { + width: 400px; + height: 65%; + } +} + +/* 모바일 (~ 768px) */ +@media (max-width: 768px) { + .dashboard { + width: 95%; + } + + .p_header { + width: 95%; + height: 45px; + } + + .p_header a { + padding: 12px; + } + + .p_header a p { + font-size: 14px; + } + + .no_project > h3 { + font-size: 28px; + margin: 100px auto 20px; + } + + .no_project > a { + width: 180px; + height: 35px; + } + + .no_project > a > p { + font-size: 16px; + line-height: 35px; + } + + .d_service { + margin: 40px auto 0; + } + + .d_service > img { + max-width: 300px; + } + + .d_service > input { + width: 70%; + font-size: 20px; + height: 32px; + } + + .d_service > textarea { + width: 90%; + height: 60px; + font-size: 14px; + } + + .d_period { + margin-top: 50px; + flex-direction: column; + align-items: flex-start; + } + + .d_period > img { + width: 30px; + height: 30px; + margin-bottom: 5px; + } + + .d_period > p { + font-size: 14px; + margin-left: 0; + } + + .d_period > p > .period_title { + font-size: 16px; + } + + .b_title > img { + width: 30px; + height: 30px; + } + + .b_title > p { + font-size: 18px; + } + + .b_content { + margin-left: 37px; + } + + hr { + margin-top: 35px; + } + + .d_team, + .d_rule, + .d_link, + .d_progress { + margin-top: 35px; + } + + .t_title > img, + .r_title > img, + .l_title > img { + width: 30px; + height: 30px; + } + + .t_title > h3, + .r_title > h3, + .l_title > h3 { + font-size: 18px; + } + + .p_title > img { + width: 40px; + height: 40px; + } + + .p_title > h3 { + font-size: 18px; + } + + .t_report > img { + width: 18px; + height: 18px; + } + + .t_report a { + font-size: 12px; + } + + .d_team > .t_content > p { + margin-left: 37px; + font-size: 16px; + } + + .d_team > .t_content > p > .t_role { + font-size: 17px; + } + + .t_member { + margin-top: 15px; + padding: 15px 0; + gap: 12px; + } + + .t_member > .member { + max-width: 160px; + height: 260px; + padding: 10px 18px; + } + + .member > .profile_section { + height: 80px; + margin-bottom: 12px; + } + + .member > .profile_section > .profile { + width: 80px; + height: 80px; + } + + .member > .profile_section > .level { + width: 28px; + height: 28px; + right: calc(50% - 45px); + } + + .member > h3 { + font-size: 16px; + margin-top: 12px; + margin-bottom: 12px; + } + + .member > .info { + font-size: 12px; + } + + .member > .role_design, + .member > .role_frontend, + .member > .role_backend { + font-size: 14px; + height: 28px; + padding: 4px 0; + bottom: 10px; + } + + .member > .role_design { + right: 40px; + } + + .member > .role_frontend { + right: 30px; + } + + .member > .role_backend { + right: 40px; + } + + .r_content, + .l_content { + margin-left: 37px; + } + + .r_content > textarea, + .l_content > textarea { + width: 90%; + height: 90px; + font-size: 13px; + line-height: 20px; + } + + .p_content { + margin: 5px 0 80px 37px; + } + + .p_bar { + height: 18px; + } + + button { + width: 180px; + height: 38px; + font-size: 16px; + margin-bottom: 150px; + } + + .modal-content { + width: 90%; + max-width: 350px; + height: 60%; + padding: 25px; + } + + .modal-header h3 { + font-size: 18px; + } + + .member-select-list { + max-height: 150px; + } + + .member-option { + padding: 10px; + } + + .member-info img { + width: 30px; + height: 30px; + } + + textarea { + height: 45px; + font-size: 13px; + } +} + +/* 소형 모바일 (~ 480px) */ +@media (max-width: 480px) { + .p_header a p { + font-size: 13px; + } + + .d_service > img { + max-width: 250px; + } + + .d_service > input { + width: 85%; + font-size: 18px; + height: 30px; + } + + .d_service > textarea { + width: 95%; + height: 55px; + font-size: 13px; + } + + .d_period > p { + font-size: 13px; + } + + .t_member > .member { + max-width: 140px; + height: 240px; + padding: 10px 15px; + } + + .member > .profile_section { + height: 70px; + } + + .member > .profile_section > .profile { + width: 70px; + height: 70px; + } + + .member > .profile_section > .level { + width: 25px; + height: 25px; + right: calc(50% - 40px); + } + + .member > h3 { + font-size: 15px; + } + + .member > .info { + font-size: 11px; + } + + .member > .role_design, + .member > .role_frontend, + .member > .role_backend { + font-size: 13px; + height: 26px; + } + + .r_content > textarea, + .l_content > textarea { + width: 95%; + font-size: 12px; + } + + button { + width: 160px; + height: 35px; + font-size: 15px; + } +} \ No newline at end of file diff --git a/static/css/email-matching-result.css b/static/css/email-matching-result.css new file mode 100644 index 0000000..00ddb0d --- /dev/null +++ b/static/css/email-matching-result.css @@ -0,0 +1,159 @@ +/* Email Template Styles - Matching Result */ + +/* Base Styles */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +.container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Header Section */ +.header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 30px 20px; + text-align: center; +} + +.header h1 { + margin: 0; + font-size: 28px; + font-weight: 600; +} + +/* Content Section */ +.content { + padding: 30px 20px; +} + +.greeting { + font-size: 18px; + color: #333; + margin-bottom: 20px; +} + +.greeting strong { + color: #667eea; +} + +/* Period Info Box */ +.period-info { + background-color: #f0f4ff; + border-left: 4px solid #667eea; + padding: 15px; + margin: 20px 0; + border-radius: 4px; +} + +.period-info p { + margin: 5px 0; + font-size: 14px; +} + +/* Team Section */ +.team-section { + margin: 30px 0; +} + +.team-section h2 { + font-size: 18px; + color: #667eea; + margin-bottom: 20px; + border-bottom: 2px solid #f0f0f0; + padding-bottom: 10px; +} + +/* Team Members Table */ +.team-members { + width: 100%; + border-collapse: collapse; + margin: 20px 0; +} + +.team-members thead { + background-color: #667eea; + color: white; +} + +.team-members th { + padding: 16px 12px; + text-align: left; + font-weight: 600; + font-size: 14px; +} + +.team-members td { + padding: 20px 12px; + border-bottom: 1px solid #e0e0e0; + vertical-align: middle; +} + +.team-members tbody tr:hover { + background-color: #f9f9f9; +} + +.team-members tbody tr:last-child td { + border-bottom: none; +} + +/* Member Details */ +.member-name { + font-weight: 600; + color: #333; + font-size: 15px; +} + +.role-badge { + display: inline-block; + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.role-pm { + background-color: #FFE5B4; + color: #FF8C00; +} + +.role-fe { + background-color: #B4E5FF; + color: #0066CC; +} + +.role-be { + background-color: #B4FFB4; + color: #006600; +} + +/* Footer Section */ +.footer { + background-color: #f5f5f5; + padding: 20px; + text-align: center; + border-top: 1px solid #e0e0e0; + font-size: 12px; + color: #666; +} + +.cta-button { + display: inline-block; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 12px 30px; + text-decoration: none; + border-radius: 25px; + font-weight: 600; + margin-top: 20px; +} diff --git a/static/css/kitup.css b/static/css/kitup.css new file mode 100644 index 0000000..e554f2d --- /dev/null +++ b/static/css/kitup.css @@ -0,0 +1,406 @@ +/* static/css/kitup.css */ + +/* 공통 배경/컨테이너 */ +.kitup-page { + background: #f6f8fc; + min-height: 100vh; +} + +.kitup-container { + max-width: 1200px; + margin: 0 auto; + padding: 48px 16px 80px; +} + +/* ========================= + LIST (기존) + ========================= */ +.kitup-title { + font-size: 28px; + font-weight: 700; + margin-bottom: 12px; +} + +.kitup-sort { + margin-bottom: 32px; + font-size: 14px; + color: #6b7280; +} + +.kitup-sort a { + color: #6b7280; + text-decoration: none; +} + +.kitup-sort a.is-active { + color: #2563eb; + font-weight: 600; +} + +.kitup-sort-sep { + margin: 0 6px; +} + +.kitup-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 32px; +} + +.kitup-card { + display: flex; + flex-direction: column; + background: #ffffff; + border-radius: 16px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06); + text-decoration: none; + color: inherit; + overflow: hidden; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.kitup-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08); +} + +.kitup-card-imgwrap { + width: 100%; + height: 180px; + background: #f1f3f5; +} + +.kitup-card-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.kitup-card-body { + padding: 16px 18px 18px; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.kitup-card-title { + font-size: 18px; + font-weight: 600; + line-height: 1.3; +} + +.kitup-card-desc { + font-size: 14px; + color: #6b7280; + line-height: 1.4; +} + +.kitup-empty { + grid-column: 1 / -1; + padding: 80px 0; + text-align: center; + color: #9ca3af; + font-size: 15px; +} + +/* 카드 하단 좋아요 영역 */ +.kitup-card-like { + display: flex; + align-items: center; + gap: 6px; + margin-top: auto; + padding-top: 8px; +} + +.kitup-like-count { + font-size: 14px; + color: #6b7280; +} + +/* ========================= + DETAIL (1번: 카드형 섹션) + ========================= */ +.kitup-detail { + max-width: 980px; +} + +/* 히어로 이미지 카드 */ +.kitup-detail-hero { + margin-top: 8px; +} + +.kitup-detail-imgwrap { + position: relative; + width: 100%; + height: 320px; + border-radius: 18px; + overflow: hidden; + background: #eef2f7; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.06); +} + +.kitup-detail-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 우상단 하트 오버레이 */ +.kitup-detail-like { + position: absolute; + top: 14px; + right: 14px; + width: 42px; + height: 42px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.10); + display: inline-flex; + align-items: center; + justify-content: center; + z-index: 9999; + pointer-events: auto; + cursor: pointer; +} + +.kitup-detail-like .kitup-heart-icon { + width: 28px; + height: 28px; +} + +/* 제목/설명 */ +.kitup-detail-head { + margin-top: 18px; +} + +.kitup-detail-title { + font-size: 26px; + font-weight: 800; + line-height: 1.2; + margin: 0 0 10px; +} + +.kitup-detail-desc { + margin: 0; + font-size: 15px; + line-height: 1.6; + color: #4b5563; +} + +/* 섹션: 카드형 박스 */ +.kitup-detail-sections { + margin-top: 22px; + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.kitup-detail-section { + background: #ffffff; + border-radius: 16px; + padding: 18px 18px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.05); +} + +.kitup-detail-h2 { + margin: 0 0 12px; + font-size: 16px; + font-weight: 700; +} + +/* 팀 메타 */ +.kitup-detail-meta { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; +} + +.kitup-detail-meta li { + display: grid; + grid-template-columns: 90px 1fr; + gap: 12px; + align-items: center; +} + +.kitup-detail-meta .k { + font-size: 13px; + color: #6b7280; +} + +.kitup-detail-meta .v { + font-size: 14px; + color: #111827; +} + +/* 멤버 목록 */ +.kitup-members { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.kitup-member { + border: 1px solid #eef2f7; + border-radius: 12px; + padding: 12px 12px; + background: #fbfdff; +} + +.kitup-member-role { + font-size: 12px; + color: #2563eb; + font-weight: 700; + margin-bottom: 6px; +} + +.kitup-member-name { + font-size: 14px; + font-weight: 600; + color: #111827; +} + +/* 링크 */ +.kitup-links { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.kitup-links a { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + text-decoration: none; + font-size: 13px; + font-weight: 600; +} + +.kitup-links a:hover { + background: #dbeafe; +} + +/* 팀 규칙 */ +.kitup-rules { + margin: 0; + padding: 14px; + border-radius: 12px; + background: #f8fafc; + border: 1px solid #eef2f7; + font-size: 13px; + line-height: 1.6; + color: #111827; + white-space: pre-wrap; +} + +/* ========================= + HEART (SVG toggle) + ========================= */ +.kitup-like-btn { + appearance: none; + -webkit-appearance: none; + border: none !important; + background: transparent !important; + padding: 0 !important; + margin: 0; + line-height: 0; + box-shadow: none !important; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.kitup-like-btn:focus { + outline: none; +} + +.kitup-heart-icon { + display: block; + background: transparent; +} + + +.kitup-heart-icon { + width: 20px; + height: 20px; + color: #9ca3af; + fill: currentColor; + transition: transform 0.12s ease, color 0.12s ease; +} + +.kitup-like-btn:hover .kitup-heart-icon { + transform: scale(1.08); +} + +.kitup-heart-icon.is-fill { + display: none; +} + +.kitup-like-btn[aria-pressed="true"] .kitup-heart-icon { + color: #ef4444; +} + +.kitup-like-btn[aria-pressed="true"] .kitup-heart-icon.is-fill { + display: inline-block; +} + +.kitup-like-btn[aria-pressed="true"] .kitup-heart-icon.is-outline { + display: none; +} + +/* sr-only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ========================= + Responsive + ========================= */ +@media (max-width: 1024px) { + .kitup-grid { + grid-template-columns: repeat(2, 1fr); + } + + .kitup-members { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .kitup-grid { + grid-template-columns: 1fr; + } + + .kitup-title { + font-size: 22px; + } + + .kitup-detail-imgwrap { + height: 240px; + } + + .kitup-members { + grid-template-columns: 1fr; + } + + .kitup-detail-meta li { + grid-template-columns: 72px 1fr; + } +} diff --git a/static/css/level_test.css b/static/css/level_test.css new file mode 100644 index 0000000..b3024db --- /dev/null +++ b/static/css/level_test.css @@ -0,0 +1,263 @@ +/* level_test.css */ + +.level-test-container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px; + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* 문항 화면 */ +.questions-screen { + background: #fff; + border-radius: 16px; + padding: 40px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.progress-bar { + width: 100%; + height: 8px; + background: #e5e7eb; + border-radius: 4px; + margin-bottom: 40px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #5b73e8, #7c8fe8); + border-radius: 4px; + transition: width 0.3s ease; +} + +.question-header { + margin-bottom: 32px; +} + +.question-number { + display: inline-block; + background: #f3f4f6; + color: #5b73e8; + font-size: 14px; + font-weight: 600; + padding: 6px 12px; + border-radius: 6px; + margin-bottom: 16px; +} + +.question-title { + font-size: 24px; + font-weight: 700; + color: #1a1a1a; + line-height: 1.4; +} + +.options-container { + margin-bottom: 40px; +} + +.option-item { + background: #f9fafb; + border: 2px solid #e5e7eb; + border-radius: 12px; + padding: 16px 20px; + margin-bottom: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.option-item:hover { + background: #f3f4f6; + border-color: #5b73e8; +} + +.option-item input[type="radio"] { + display: none; +} + +.option-item input[type="radio"]:checked + label { + color: #5b73e8; + font-weight: 600; +} + +.option-item:has(input[type="radio"]:checked) { + background: #eef2ff; + border-color: #5b73e8; +} + +.option-item label { + display: block; + width: 100%; + font-size: 16px; + color: #374151; + cursor: pointer; +} + +.button-container { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.btn-prev, +.btn-next, +.btn-submit { + padding: 14px 32px; + font-size: 16px; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-prev { + background: #f3f4f6; + color: #6b7280; +} + +.btn-prev:hover { + background: #e5e7eb; +} + +.btn-next, +.btn-submit { + background: #5b73e8; + color: #fff; +} + +.btn-next:hover, +.btn-submit:hover { + background: #4a5fc8; +} + +/* 결과 화면 */ +.result-screen { + display: flex; + align-items: center; + justify-content: center; + min-height: 500px; +} + +.result-content { + text-align: center; + max-width: 500px; +} + +.result-icon { + font-size: 64px; + margin-bottom: 24px; +} + +.result-content h2 { + font-size: 28px; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 16px; +} + +.result-content > p { + font-size: 18px; + color: #666; + margin-bottom: 24px; +} + +.result-content > p span { + color: #5b73e8; + font-weight: 600; +} + +.result-level-badge { + display: inline-flex; + align-items: baseline; + gap: 8px; + background: linear-gradient(135deg, #5b73e8, #7c8fe8); + color: #fff; + padding: 24px 48px; + border-radius: 16px; + margin: 24px 0; + box-shadow: 0 8px 24px rgba(91, 115, 232, 0.3); +} + +.level-text { + font-size: 24px; + font-weight: 600; +} + +.level-number { + font-size: 48px; + font-weight: 700; +} + +.result-score { + font-size: 18px; + color: #374151; + font-weight: 600; + margin: 16px 0 24px 0; +} + +.result-score span { + color: #5b73e8; + font-size: 20px; +} + +.result-description { + font-size: 16px; + color: #666; + line-height: 1.6; + margin-bottom: 40px; +} + +.btn-home { + background: #5b73e8; + color: #fff; + border: none; + border-radius: 8px; + padding: 14px 40px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.3s ease; +} + +.btn-home:hover { + background: #4a5fc8; +} + +/* 반응형 */ +@media (max-width: 768px) { + .level-test-container { + padding: 20px 16px; + } + + .questions-screen { + padding: 24px 20px; + } + + .question-title { + font-size: 20px; + } + + .button-container { + flex-direction: row; + } + + .btn-prev, + .btn-next, + .btn-submit { + width: 50%; + } + + .result-level-badge { + padding: 20px 40px; + } + + .level-text { + font-size: 20px; + } + + .level-number { + font-size: 40px; + } +} \ No newline at end of file diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..639af2e --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,352 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Apple SD Gothic Neo', sans-serif; + background-color: #f5f5f5; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 20px; + margin: 0; +} + +.login-container { + /* position: relative; */ + width: 450px; +} + + +.login-box { + background: white; + border-radius: 16px; + padding: 48px 75px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + width: 100%; +} + +.logo { + text-align: center; + margin-bottom: 32px; +} + +.logo h3 { + font-size: 32px; + color: #4285f4; +} + +.login-title { + font-size: 24px; + font-weight: 600; + text-align: center; + margin-bottom: 32px; + color: #333; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.input-group { + /* position: relative; */ +} + +.input-group input { + width: 100%; + padding: 14px 16px; + border: 1px solid #e0e0e0; + border-radius: 8px; + font-size: 15px; + transition: all 0.3s ease; + background-color: #fafafa; + align-items: center; + +} + +.input-group input:focus { + outline: none; + border-color: #4285f4; + background-color: white; +} + +.input-group input::placeholder { + color: #999; +} + +.login-button { + width: 100%; + padding: 14px; + background: #4285f4; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + margin-top: 8px; + transition: background 0.3s ease; +} + +.login-button:hover { + background: #3367d6; +} + +.login-button:active { + background: #2851a3; +} + +.links { + display: flex; + justify-content: space-between; + margin-top: 20px; + padding: 0 4px; +} + +.link { + font-size: 14px; + color: #4285f4; + text-decoration: none; + transition: color 0.3s ease; +} + +.link:hover { + color: #3367d6; + text-decoration: underline; +} + +.social-login { + + justify-content: center; + gap: 16px; + margin-top: 32px; + padding-top: 32px; + border-top: 1px solid #e0e0e0; +} + +.social-icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: white; + border: 0.5px solid #e0e0e0; + transition: all 0.3s ease; + cursor: pointer; +} + +.social-icon:hover { + border-color: #4285f4; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(66, 133, 244, 0.2); +} + +.social-icon img { + width: 100%; + height: 100%; + border-radius: 50%; +} + +/* =================================== + 반응형 미디어 쿼리 추가 + =================================== */ + +/* 태블릿 (768px 이하) */ +@media (max-width: 768px) { + .login-container { + width: 400px; + } + + .login-box { + width: 100%; + padding: 40px 60px; + } + + .logo h3 { + font-size: 28px; + } + + .login-title { + font-size: 22px; + margin-bottom: 28px; + } + + .input-group input { + padding: 12px 14px; + font-size: 14px; + } + + .login-button { + padding: 12px; + font-size: 15px; + } + + .links { + margin-top: 18px; + } + + .link { + font-size: 13px; + } + + .social-login { + margin-top: 28px; + padding-top: 28px; + } + + .social-title { + font-size: 13px; + } + + .social-icons { + gap: 14px; + } + + .social-icon { + width: 42px; + height: 42px; + } + + .social-icon img { + width: 38px; + height: 38px; + } +} + +/* 모바일 (480px 이하) */ +@media (max-width: 480px) { + body { + padding: 15px; + } + + .login-container { + width: 350px; + } + + .login-box { + padding: 32px 24px; + width: 100%; + } + + .logo { + margin-bottom: 24px; + } + + .logo h3 { + font-size: 26px; + } + + .login-title { + font-size: 20px; + margin-bottom: 24px; + } + + .login-form { + gap: 14px; + } + + .input-group input { + padding: 12px 14px; + font-size: 14px; + } + + .login-button { + padding: 12px; + font-size: 14px; + margin-top: 6px; + } + + .links { + margin-top: 16px; + flex-direction: column; + gap: 10px; + text-align: center; + } + + .link { + font-size: 13px; + } + + .social-login { + margin-top: 24px; + padding-top: 24px; + } + + .social-title { + font-size: 13px; + margin-bottom: 10px; + } + + .social-icons { + gap: 12px; + } + + .social-icon { + width: 40px; + height: 40px; + } + + .social-icon img { + width: 36px; + height: 36px; + } + + .error-messages, .messages { + font-size: 13px; + } + + .error { + font-size: 13px; + } +} + +/* 극소형 모바일 (360px 이하) */ +@media (max-width: 360px) { + .login-container { + width: 320px; + } + + .login-box { + width: 100%; + padding: 28px 20px; + } + + .logo h3 { + font-size: 24px; + } + + .login-title { + font-size: 18px; + } + + .input-group input { + padding: 10px 12px; + font-size: 13px; + } + + .login-button { + padding: 10px; + font-size: 13px; + } + + .social-icons { + gap: 10px; + } + + .social-icon { + width: 38px; + height: 38px; + } + + .social-icon img { + width: 34px; + height: 34px; + } +} \ No newline at end of file diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..ce5da02 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,777 @@ +body { + font-family: + "Pretendard Variable", + Pretendard, + -apple-system, + BlinkMacSystemFont, + system-ui, + Roboto, + "Helvetica Neue", + "Segoe UI", + "Apple SD Gothic Neo", + "Noto Sans KR", + "Malgun Gothic", + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + sans-serif; + margin: 0; + padding: 0; +} + +/* 메인 */ +.main-container { + width: 85%; + margin: 0 auto; +} + +.main-container img { + width: 100%; + margin-top: 30px; +} + +/* 회고 */ +.main-note { + width: 85%; + margin: 100px auto 0; +} + +.main-note .main-note-title { + width: 100%; + display: flex; + align-items: center; +} + +.main-note .main-note-title img { + width: 7%; +} + +.main-note .main-note-title h3 { + font-size: 25px; +} + +.main-note .main-note-content { + margin-top: 25px; + width: 100%; + min-height: 100px; + padding: 35px 45px; + background: #f9f9f9; + border-radius: 20px; +} + +.reflection_list { + display: flex; + flex-direction: column; + gap: 15px; + overflow-x: auto; + padding-bottom: 10px; +} + +.login_content { + display: flex; + align-items: center; + gap: 20px; +} + +.login_content .m_title { + width: 20%; + font-weight: 600; +} + +.login_content .m_content { + width: 70%; +} + +.login_content .m_date { + width: 10%; + font-size: 16px; + color: #999999; +} + +.main-note .main-note-content .unlogin_content { + font-size: 20px; + font-weight: 600; + text-align: center; + align-items: center; +} + +.m_note_btn { + display: block; + width: 20%; + margin: 30px auto; + background: #1D294B; color: #fff; + font-weight: 500; font-size: 16px; + border-radius: 20px; padding: 7px 30px; +} + +.m_note_btn:hover { + cursor: pointer; + background: #1A3C97; + transition: 0.3s ease; +} + +/* 팀매칭 */ +.main-team { + margin-top: 90px; + width: 100%; + min-height: 200px; + padding: 90px 0; + background: #f6f8ff; + text-align: center; +} + +.main-team h3 { + margin-bottom: 20px; + font-size: 35px; +} + +/* 팀 매칭 기간 O 매칭 신청 X */ +.main-team .m_team_stack { + width: 90%; + margin: 40px auto 0; + display: flex; + justify-content: space-between; +} + +/* WEB 기획 */ +.main-team .m_team_stack .m_design { + padding: 25px 0; + width: 30%; + height: 10%; + text-align: center; + background: #fff; + border: 3px solid #00b9b050; + border-radius: 40px; +} + +.main-team .m_team_stack .m_design h3 { + font-weight: 600; + font-size: 25px; +} + +.main-team .m_team_stack .m_design img { + width: 50px; + height: 50px; +} + +.main-team .m_team_stack .m_nolevel { + margin-top: 5px; + color: #ff0202; + font-size: 16px; +} + +.m_design_btn { + margin-top: 15px; + padding: 10px 0; + width: 250px; + height: 45px; + background: #00b9b0; + color: #fff; + border: none; + border-radius: 30px; + font-size: 18px; + font-weight: 550; +} + +.m_design_btn:hover { + background: #019890; + cursor: pointer; + transition: 0.3s ease; +} + +/* WEB 프론트엔드 */ +.main-team .m_team_stack .m_frontend { + padding: 25px 0; + width: 30%; + height: 5%; + text-align: center; + background: #fff; + border: 3px solid #ffce5350; + border-radius: 40px; +} + +.main-team .m_team_stack .m_frontend h3 { + font-weight: 600; + font-size: 25px; +} + +.main-team .m_team_stack .m_frontend img { + width: 50px; + height: 50px; +} + +.main-team .m_team_stack .m_frontend .m_level { + margin-top: 5px; + color: #000; + font-size: 16px; +} + +.m_front_btn { + margin-top: 15px; + padding: 5px 0; + width: 250px; + height: 45px; + background: #ffce53; + color: #fff; + border: none; + border-radius: 30px; + font-size: 18px; + font-weight: 550; +} + +.m_front_btn:hover { + cursor: pointer; + background: #E2BF67; + transition: 0.3s ease; +} + +/* WEB 백엔드 */ +.main-team .m_team_stack .m_backend { + padding: 25px 0; + width: 30%; + height: 5%; + text-align: center; + background: #fff; + border: 3px solid #ff3e8850; + border-radius: 40px; +} + +.main-team .m_team_stack .m_backend h3 { + font-weight: 600; + font-size: 25px; +} + +.main-team .m_team_stack .m_backend img { + width: 50px; + height: 50px; +} + +.main-team .m_team_stack .m_backend .m_level { + margin-top: 5px; + color: #000; + font-size: 16px; +} + +.m_back_btn { + margin-top: 15px; + padding: 10px 0; + width: 250px; + height: 45px; + background: #ff3e88; + color: #fff; + border: none; + border-radius: 30px; + font-size: 18px; + font-weight: 550; +} + +.m_back_btn:hover { + cursor: pointer; + background: #C03067; + transition: 0.3s ease; +} + +/* 팀 매칭 결과 */ +.m_team_result { + display: block; + width: 18%; height: 40px; + margin: 30px auto 0; + padding: 5px 10px; + text-decoration: none; + background: #1F4CC0; + color: #fff; + border-radius: 20px; +} + +.m_team_result:hover { + background: #4272EF; + transition: 0.3s ease-in-out; +} + +.m_team_result p { + font-weight: 550; font-size: 18px; + line-height: 30px; +} + +/* 팀 매칭 O 매칭 결과 X */ +.team_waiting { + margin-top: 120px; + text-align: center; + align-items: center; +} + +.team_waiting > h3 { + font-size: 27px; + font-weight: 600; + margin-bottom: 20px; +} + +.team_waiting > form { + display: flex; + justify-content: space-between; + align-items: center; + margin: 30px auto 0; + width: 60%; height: 40px; + background: #fff; + border-radius: 25px; + padding: 10px 5px 10px 20px; +} + +.team_waiting > form:focus-within { + border: 1px solid #4272EF; + box-shadow: 0 2px 15px rgba(66, 114, 239, 0.2); +} + +.team_waiting > form > input { + width: 80%; + border: none; + font-size: 15px; + color: #888888; + outline: none; +} + +.team_waiting > form > input::placeholder { + color: #888888; +} + +.team_waiting > form > button { + font-size: 15px; + background: #4272EF; color: #fff; + border: none; border-radius: 20px; + padding: 7px 15px; +} + +.team_waiting > form > button:hover { + background: #1F4CC0; + transition: 0.3s ease; +} + +/* 매칭 취소 */ +.matching_actions { + margin-top: 30px; + display: flex; + justify-content: center; +} + +.cancel_form { + width: 100%; + display: flex; + justify-content: center; +} + +.cancel_button { + width: 150px; + height: 40px; + padding: 10px 20px; + background: #FF6B6B; + color: #fff; + border: none; + border-radius: 20px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: 0.3s ease; +} + +.cancel_button:hover { + background: #E63946; + transition: 0.3s ease; +} + +/* KITUP 프로젝트 */ +.main-project { + width: 85%; + margin: 0 auto; +} + +.main-project .m_project_title { + margin-top: 100px; + display: flex; + align-items: center; +} + +.main-project .m_project_title img { + width: 7%; +} + +.main-project .m_project_title h3 { + font-size: 25px; + padding: 12px 4px 0; +} + +.main-project .m_project_content { + margin-top: 25px; + width: 100%; + min-height: 100px; +} + +.main-project .m_project_content .m_noproject { + font-size: 20px; + font-weight: 600; + text-align: center; + align-items: center; + padding: 35px 45px; + background: #f9f9f9; + border-radius: 20px; + text-align: center; +} + +.main-project .m_project_content .m_project { + display: flex; + gap: 30px; + width: 100%; + overflow-x: auto; + padding-bottom: 10px; + -webkit-overflow-scrolling: touch; +} + +.main-project .m_project_content .m_project > div { + flex: 0 0 300px; + height: 300px; + padding: 20px; + background: #EAF0FF; + border-radius: 20px; +} + +.main-project .m_project_content .m_project div img { + border-radius: 15px; + height: 65%; +} + +.main-project .m_project_content .m_project div h3 { + font-size: 22px; font-weight: 600; + padding: 10px 0; +} + +.main-project .m_project_content .m_project div p { + font-size: 16px; +} + +/* 푸터 */ +footer { + background: #1d294b; + color: #ccc; + text-align: center; + padding: 50px 0; + margin-top: 200px; + width: 100%; + height: 200px; + font-size: 12px; +} + +footer > a { + display: block; + text-decoration: none; + color: #ccc; +} + +footer p { + margin: 10px auto; +} + +/* ======================================== + 반응형 미디어 쿼리 +======================================== */ + +/* 데스크탑 (1200px 이상) - 기본 스타일 유지 */ +@media (min-width: 1200px) { + .main-container, + .main-note, + .main-project { + width: 85%; + } +} + +/* 노트북 (768px ~ 1199px) */ +@media (max-width: 1199px) { + .main-container, + .main-note, + .main-project { + width: 90%; + } + + .main-note .main-note-title h3 { + font-size: 22px; + } + + .main-team h3 { + font-size: 30px; + } + + .main-team .m_team_stack .m_design h3, + .main-team .m_team_stack .m_frontend h3, + .main-team .m_team_stack .m_backend h3 { + font-size: 22px; + } + + .m_design_btn, + .m_front_btn, + .m_back_btn { + width: 80%; + font-size: 16px; + } + + .m_note_btn { + width: 25%; + } + + .m_team_result { + width: 22%; + } +} + +/* 태블릿 (481px ~ 767px) */ +@media (max-width: 767px) { + .main-container, + .main-note, + .main-project { + width: 92%; + } + + .main-note .main-note-title img, + .main-project .m_project_title img { + width: 10%; + } + + .main-note .main-note-title h3, + .main-project .m_project_title h3 { + font-size: 20px; + } + + .main-note .main-note-content { + padding: 25px 30px; + } + + .login_content { + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 15px 0; + border-bottom: 1px solid #e0e0e0; + } + + .login_content .m_title, + .login_content .m_content, + .login_content .m_date { + width: 100%; + } + + .login_content .m_date { + font-size: 14px; + } + + .m_note_btn { + width: 40%; + font-size: 15px; + } + + .main-team { + padding: 70px 0; + } + + .main-team h3 { + font-size: 24px; + padding: 0 20px; + } + + .main-team p { + padding: 0 20px; + font-size: 15px; + } + + .main-team .m_team_stack { + flex-direction: column; + width: 85%; + gap: 25px; + } + + .main-team .m_team_stack .m_design, + .main-team .m_team_stack .m_frontend, + .main-team .m_team_stack .m_backend { + width: 100%; + padding: 30px 20px; + } + + .main-team .m_team_stack .m_design h3, + .main-team .m_team_stack .m_frontend h3, + .main-team .m_team_stack .m_backend h3 { + font-size: 22px; + } + + .m_design_btn, + .m_front_btn, + .m_back_btn { + width: 80%; + max-width: 280px; + } + + .m_team_result { + width: 45%; + } + + .team_waiting > form { + width: 85%; + } + + .main-project .m_project_content .m_project > div { + flex: 0 0 260px; + height: 280px; + } +} + +/* 모바일 (480px 이하) */ +@media (max-width: 480px) { + .main-container, + .main-note, + .main-project { + width: 95%; + } + + .main-container img { + margin-top: 20px; + } + + .main-note { + margin-top: 60px; + } + + .main-note .main-note-title img, + .main-project .m_project_title img { + width: 12%; + } + + .main-note .main-note-title h3, + .main-project .m_project_title h3 { + font-size: 18px; + } + + .main-note .main-note-content { + padding: 20px 20px; + margin-top: 20px; + } + + .login_content .m_title { + font-size: 15px; + } + + .login_content .m_content { + font-size: 14px; + } + + .main-note .main-note-content .unlogin_content { + font-size: 16px; + } + + .m_note_btn { + width: 50%; + font-size: 14px; + padding: 8px 20px; + } + + .main-team { + margin-top: 60px; + padding: 50px 0; + } + + .main-team h3 { + font-size: 20px; + padding: 0 15px; + margin-bottom: 15px; + } + + .main-team p { + padding: 0 15px; + font-size: 14px; + } + + .main-team .m_team_stack { + width: 90%; + gap: 20px; + margin-top: 30px; + } + + .main-team .m_team_stack .m_design, + .main-team .m_team_stack .m_frontend, + .main-team .m_team_stack .m_backend { + padding: 25px 15px; + border-radius: 30px; + } + + .main-team .m_team_stack .m_design h3, + .main-team .m_team_stack .m_frontend h3, + .main-team .m_team_stack .m_backend h3 { + font-size: 19px; + } + + .main-team .m_team_stack .m_nolevel { + font-size: 14px; + } + + .m_design_btn, + .m_front_btn, + .m_back_btn { + width: 90%; + max-width: 250px; + font-size: 16px; + height: 42px; + } + + .m_team_result { + width: 60%; + font-size: 16px; + } + + .m_team_result p { + font-size: 16px; + } + + .team_waiting > h3 { + font-size: 20px; + padding: 0 15px; + } + + .team_waiting > form { + width: 90%; + } + + .cancel_button { + width: 130px; + font-size: 14px; + } + + .main-project { + margin-bottom: 30px; + } + + .main-project .m_project_title { + margin-top: 60px; + } + + .main-project .m_project_content { + margin-top: 20px; + } + + .main-project .m_project_content .m_noproject { + font-size: 16px; + padding: 25px 20px; + } + + .main-project .m_project_content .m_project > div { + flex: 0 0 240px; + height: 260px; + padding: 15px; + } + + .main-project .m_project_content .m_project div h3 { + font-size: 19px; + } + + .main-project .m_project_content .m_project div p { + font-size: 14px; + } + + footer { + margin-top: 100px; + height: 120px; + } +} \ No newline at end of file diff --git a/static/css/mission.css b/static/css/mission.css new file mode 100644 index 0000000..8a826c7 --- /dev/null +++ b/static/css/mission.css @@ -0,0 +1,732 @@ +/* 프로젝트 헤더 */ +.p_header { + width: 90%; + height: 50px; + margin: 0 auto; + display: flex; +} + +.p_header a { + display: block; + width: 50%; + text-align: center; + padding: 15px; + text-decoration: none; + background: #F6F8FF; +} + +.p_header .p_dashboard { + background: #fff; + color: #EAF0FF; +} + +.p_header .p_mission { + background: #F6F8FF; + color: #1d294b; +} + +.p_header .p_dashboard:hover { + background: #EAF0FF; + color: #1d294b; + transition: 0.3s ease-in-out; +} + +.p_header a p { + font-size: 18px; + font-weight: 700; +} + +/* 진척도 */ +.mission_progress { + background: #F6F8FF; + width: 90%; + height: 350px; + padding: 50px 20px; + margin: 0 auto 80px; +} + +.mp_title { + display: flex; + align-items: center; +} + +.mp_title > img { + width: 100px; + height: 100px; +} + +.mp_title > div > h3 { + font-size: 25px; + font-weight: 600; + margin-bottom: 15px; +} + +.mp_title > div > p { + font-size: 14px; + color: #FF0000; +} + +/* 진척도 바 */ +.role_progress_bar { + margin: 20px 0 15px 100px; + display: flex; + align-items: center; + gap: 15px; +} + +.role_progress_bar > h3 { + width: 30px; + font-size: 20px; + font-weight: 550; +} + +.progress_bar_container { + position: relative; + flex: 1; + height: 15px; + background: #DDDDDD; + border-radius: 20px; + overflow: hidden; +} + +.progress_bar_fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + border-radius: 20px; + transition: width 0.5s ease; +} + +.progress_bar_fill.pm_color { + background: #37D3BF; +} + +.progress_bar_fill.fe_color { + background: #FFDF6E; +} + +.progress_bar_fill.be_color { + background: #FF69A4; +} + +.progress_percent { + min-width: 45px; + font-size: 14px; + font-weight: 600; + color: #4272EF; +} + +/* 미션 아이템 */ +.mission_item { + width: 90%; + margin: 0 auto; + display: flex; + gap: 20px; +} + +/* 타임라인 */ +.timeline_dot { + display: flex; + flex-direction: column; + align-items: center; + width: 80px; + flex-shrink: 0; +} + +.circle { + width: 30px; + height: 30px; + background: #EAF0FF; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 5; + position: relative; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 700; + color: #1d294b; + transition: all 0.3s ease; +} + +.mission_item.active .circle { + background: #4272EF; + color: #fff; + box-shadow: 0 4px 12px rgba(66, 114, 239, 0.4); + transform: scale(1.1); +} + +.mission_item.completed .circle { + background: #4272EF; + color: #fff; + box-shadow: 0 4px 12px rgba(66, 114, 239, 0.4); +} + +.line { + width: 3px; + height: 100%; + background: #EAF0FF; + flex: 1; + margin-top: -10px; + transition: background 0.3s ease; +} + +.mission_item.completed .line { + background: #4272EF; +} + +.mission_item:last-child .line { + display: none; +} + +/* 카드 래퍼 */ +.card_wrapper { + flex: 1; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 미션 카드 */ +.mission_card { + background: #F8F9FF; + border: 2px solid #E0E7FF; + border-radius: 20px; + margin-bottom: 25px; + padding: 20px 25px; + cursor: pointer; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 8px rgba(66, 114, 239, 0.08); +} + +.mission_card:hover { + border-color: #4272EF; + box-shadow: 0 4px 12px rgba(66, 114, 239, 0.15); +} + +.card_header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.card_header h3 { + font-size: 18px; + font-weight: 600; + color: #4272EF; + margin: 0; +} + +.check_icon { + width: 28px; + height: 28px; + transition: transform 0.3s ease; +} + +.mission_card:hover .check_icon { + transform: scale(1.1); +} + +/* 카드 내용 (기본 숨김) */ +.card_content { + max-height: 0; + overflow: hidden; + opacity: 0; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + margin-top: 0; +} + +/* 활성화된 카드 */ +.mission_card.active { + background: #fff; + border-color: #4272EF; + box-shadow: 0 8px 24px rgba(66, 114, 239, 0.2); + transform: scale(1.02); +} + +.mission_card.active .card_content { + max-height: 500px; + opacity: 1; + margin-top: 15px; +} + +.mission_card.completed { + opacity: 0.8; +} + +/* 마크다운 콘텐츠 스타일 */ +.mission_description { + font-size: 14px; + color: #374151; + line-height: 1.6; +} + +.mission_description p { + margin: 10px 0; +} + +.mission_description ul, +.mission_description ol { + margin: 10px 0 10px 20px; +} + +.mission_description li { + margin-bottom: 8px; + line-height: 1.6; +} + +.mission_description strong { + color: #4272EF; + font-weight: 600; +} + +.mission_description em { + color: #6B7280; + font-style: italic; + font-size: 13px; +} + +.mission_description code { + background: #F3F4F6; + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; +} + +.mission_description p { + margin: 8px 0; + white-space: pre-wrap; +} + +.mission_description strong { + color: #4272EF; + font-weight: 600; +} + +.mission_description ul { + list-style: disc; + margin-left: 20px; +} + +.mission_description li { + margin-bottom: 12px; + line-height: 1.6; +} + +.mission_description li em { + display: block; + margin-top: 4px; + color: #6B7280; + font-size: 13px; +} + +.m_footer { + width: 90%; + margin: 10% auto; + text-align: center; + font-size: 25px; font-weight: 550; +} + +.m_footer > span { + font-weight: 600; + color: #4272EF; +} + +/* ======================================== + 반응형 미디어 쿼리 +======================================== */ + +/* 데스크탑 (1200px 이상) - 기본 스타일 유지 */ +@media (min-width: 1200px) { + .p_header { + width: 90%; + } + + .mission_progress { + width: 90%; + } + + .mission_item { + width: 90%; + } +} + +/* 노트북 (768px ~ 1199px) */ +@media (max-width: 1199px) { + .p_header { + width: 92%; + } + + .p_header a p { + font-size: 17px; + } + + .mission_progress { + width: 92%; + height: auto; + padding: 40px 20px; + } + + .mp_title > img { + width: 90px; + height: 90px; + } + + .mp_title > div > h3 { + font-size: 23px; + } + + .mp_title > div > p { + font-size: 13px; + } + + .role_progress_bar { + margin: 18px 0 12px 90px; + } + + .role_progress_bar > h3 { + font-size: 19px; + } + + .progress_bar_container { + height: 14px; + } + + .progress_percent { + font-size: 13px; + } + + .mission_item { + width: 92%; + } + + .timeline_dot { + width: 70px; + } + + .circle { + width: 28px; + height: 28px; + font-size: 15px; + } + + .mission_card { + padding: 18px 22px; + } + + .card_header h3 { + font-size: 17px; + } + + .check_icon { + width: 26px; + height: 26px; + } + + .mission_description { + font-size: 13px; + } + + .m_footer { + margin: 8% auto; + font-size: 23px; + } +} + +/* 태블릿 (481px ~ 767px) */ +@media (max-width: 767px) { + .p_header { + width: 95%; + height: 45px; + } + + .p_header a { + padding: 12px; + } + + .p_header a p { + font-size: 16px; + } + + .mission_progress { + width: 95%; + padding: 35px 15px; + margin-bottom: 60px; + } + + .mp_title { + flex-direction: column; + align-items: flex-start; + } + + .mp_title > img { + width: 70px; + height: 70px; + margin-bottom: 15px; + } + + .mp_title > div > h3 { + font-size: 20px; + margin-bottom: 10px; + } + + .mp_title > div > p { + font-size: 12px; + } + + .role_progress_bar { + margin: 15px 0 10px 0; + gap: 10px; + } + + .role_progress_bar > h3 { + width: 25px; + font-size: 17px; + } + + .progress_bar_container { + height: 12px; + } + + .progress_percent { + min-width: 40px; + font-size: 12px; + } + + .mission_item { + width: 95%; + gap: 15px; + } + + .timeline_dot { + width: 60px; + } + + .circle { + width: 26px; + height: 26px; + font-size: 14px; + } + + .line { + width: 2px; + } + + .mission_card { + padding: 16px 18px; + margin-bottom: 20px; + border-radius: 15px; + } + + .card_header h3 { + font-size: 16px; + } + + .check_icon { + width: 24px; + height: 24px; + } + + .mission_description { + font-size: 13px; + } + + .mission_description li { + margin-bottom: 10px; + } + + .mission_description em { + font-size: 12px; + } + + .mission_card.active .card_content { + max-height: 600px; + } + + .m_footer { + margin: 15% auto; + font-size: 19px; + padding: 0 20px; + line-height: 1.5; + } +} + +/* 모바일 (480px 이하) */ +@media (max-width: 480px) { + .p_header { + width: 100%; + height: 40px; + } + + .p_header a { + padding: 10px; + } + + .p_header a p { + font-size: 14px; + } + + .mission_progress { + width: 95%; + padding: 25px 12px; + margin-bottom: 50px; + } + + .mp_title { + flex-direction: column; + align-items: flex-start; + } + + .mp_title > img { + width: 60px; + height: 60px; + margin-bottom: 12px; + } + + .mp_title > div > h3 { + font-size: 18px; + margin-bottom: 8px; + } + + .mp_title > div > p { + font-size: 11px; + line-height: 1.4; + } + + .role_progress_bar { + margin: 12px 0 8px 0; + gap: 8px; + } + + .role_progress_bar > h3 { + width: 23px; + font-size: 15px; + } + + .progress_bar_container { + height: 10px; + } + + .progress_percent { + min-width: 38px; + font-size: 11px; + } + + .mission_item { + width: 95%; + gap: 12px; + } + + .timeline_dot { + width: 50px; + } + + .circle { + width: 24px; + height: 24px; + font-size: 13px; + } + + .line { + width: 2px; + margin-top: -8px; + } + + .mission_card { + padding: 14px 15px; + margin-bottom: 18px; + border-radius: 12px; + } + + .mission_card:hover { + transform: none; + } + + .mission_card.active { + transform: none; + } + + .card_header { + align-items: flex-start; + } + + .card_header h3 { + font-size: 15px; + line-height: 1.4; + padding-right: 5px; + } + + .check_icon { + width: 22px; + height: 22px; + flex-shrink: 0; + } + + .mission_card.active .card_content { + max-height: 700px; + margin-top: 12px; + } + + .mission_description { + font-size: 12px; + } + + .mission_description p { + margin: 6px 0; + } + + .mission_description ul, + .mission_description ol { + margin: 8px 0 8px 15px; + } + + .mission_description li { + margin-bottom: 8px; + line-height: 1.5; + } + + .mission_description li em { + font-size: 11px; + } + + .mission_description code { + font-size: 11px; + padding: 1px 4px; + } + + .m_footer { + margin: 20% auto; + font-size: 16px; + padding: 0 15px; + line-height: 1.6; + } + + .no_project { + margin-top: 40%; + text-align: center; + } + + .no_project h3 { + font-size: 18px; + padding: 0 15px; + } +} + +@media (max-width: 767px) { + .mission_card { + -webkit-tap-highlight-color: transparent; + } + + .check_icon { + padding: 5px; + margin: -5px; + } + + .mission_card:hover { + box-shadow: 0 2px 8px rgba(66, 114, 239, 0.08); + } + + .mission_card:active { + box-shadow: 0 4px 12px rgba(66, 114, 239, 0.15); + } +} \ No newline at end of file diff --git a/static/css/mypage.css b/static/css/mypage.css new file mode 100644 index 0000000..e634a9e --- /dev/null +++ b/static/css/mypage.css @@ -0,0 +1,530 @@ +/* mypage.css */ + +.mypage-wrapper { + max-width: 1400px; + margin: 0 auto; + padding: 40px 20px; + background-color: #fff; +} + +.profile-card { + background-color: #fff; + border-radius: 20px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + overflow: visible; + position: relative; +} + +/* 상단 배너 영역 */ +.profile-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 160px; + background-color: #EAF0FF; + border-radius: 20px 20px 0 0; + z-index: 0; +} + +/* 카드 내부 컨테이너 */ +.profile-card { + display: flex; + padding-top: 160px; + position: relative; +} + +/* 왼쪽 프로필 영역 - 50% */ +.user-info-side { + flex: 0 0 50%; + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 50px 50px; + position: relative; + z-index: 1; +} + + +.avatar-container { + position: absolute; + top: -100px; + width: 180px; + height: 180px; /* 또는 aspect-ratio: 1 / 1; */ + border-radius: 50%; + background-color: #d1d5db; + border: 6px solid #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 1; +} + +.profile-img { + width: 100%; + height: 100%; + border-radius: 50%; + display: block; /* img 아래 여백/라인박스 문제 제거 */ + object-fit: cover; /* 핵심 */ + object-position: center; + z-index: 1; +} + + +.edit-badge { + position: absolute; + bottom: 10px; + right: 10px; + width: 42px; + height: 42px; + background-color: #6b7280; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 3px solid #fff; + transition: background-color 0.2s; + z-index: 2; +} + +.edit-badge:hover { + background-color: #4b5563; +} + +.edit-badge img { + width: 20px; + height: 20px; + filter: brightness(0) invert(1); +} + +/* 정보 테이블 */ +.info-table { + margin-top: 50px; + width: 100%; + max-width: 450px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.info-row { + display: flex; + align-items: center; +} + +.info-row .label { + font-weight: 700; + font-size: 18px; + color: #000; + min-width: 90px; +} + +.info-row .value { + font-size: 17px; + color: #4b5563; + margin-left: 40px; +} + +/* 프로필 수정 버튼 */ +.edit-button { + margin-top: 70px; + width: 100%; + max-width: 240px; + padding: 14px 0; + background-color: #4F75FF; + color: white; + border: none; + border-radius: 10px; + font-size: 17px; + font-weight: 700; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; + transition: background-color 0.2s; +} + +.edit-button:hover { + background-color: #3d5dd1; +} + +/* 오른쪽 콘텐츠 영역 - 50% */ +.content-side { + flex: 0 0 50%; + display: flex; + flex-direction: column; + gap: 25px; + padding: 40px 50px 50px; + position: relative; + z-index: 1; +} + +/* 기술 스택 박스 */ +.tech-stack-box { + background-color: white; + border: 2px solid #A4BDFD; + border-radius: 20px; + padding: 35px; +} + +.side-title { + font-size: 20px; + font-weight: 700; + color: #000; + margin: 0 0 24px 0; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.tag-item { + padding: 12px 24px; + border-radius: 30px; + font-size: 17px; + font-weight: 600; + border: 2px solid; +} + +/* 기술 스택 카테고리별 색상 */ +.tag-frontend { + color: #FFC107; + border-color: #FFC107; + background-color: transparent; +} + +.tag-backend { + color: #FF3E88; + border-color: #FF3E88; + background-color: transparent; +} + +.tag-pm { + color: #06D6A0; + border-color: #06D6A0; + background-color: transparent; +} + +/* 역할별 색상 */ +.tag-frontend { + color: #FFC107; + border-color: #FFC107; + background-color: transparent; +} + +.tag-backend { + color: #FF3E88; + border-color: #FF3E88; + background-color: transparent; +} + + +.tag-html { + color: #00B4D8; + border-color: #00B4D8; + background-color: transparent; +} + +/* 프로젝트 그리드 */ +.project-list-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.project-item-card { + background-color: #fff; + border: 2px solid #A4BDFD; + border-radius: 20px; + padding: 35px; + min-height: 160px; + display: flex; + flex-direction: column; +} + +.project-item-card.empty { + opacity: 0.7; +} + +.item-label { + font-size: 17px; + font-weight: 700; + color: #1f2937; + margin: 0 0 15px 0; +} + +.item-project-title { + font-size: 19px; + font-weight: 700; + color: #000; + margin: 0 0 auto 0; +} + +.item-role-tag { + display: inline-block; + align-self: flex-start; + padding: 8px 18px; + background-color: #6B8AFF; + color: white; + border-radius: 18px; + font-size: 14px; + font-weight: 600; + margin-top: 18px; +} + +.empty-msg { + color: #9ca3af; + font-size: 15px; +} + + +/* 나의 레벨 섹션 */ +.my-levels-section { + width: 100%; + max-width: 450px; + margin-top: 60px; + background-color: rgba(255, 255, 255, 0.849); + border-width: 2px; + border-style: solid; + border-color: rgb(223, 223, 223); + border-image: initial; + border-radius: 16px; + padding: 20px 25px; +} + + +.levels-title { + font-size: 17px; + font-weight: 700; + color: #000; + margin: 0 0 16px 0; +} + +.level-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.level-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; +} + +.role-name { + font-size: 14px; + font-weight: 600; + min-width: 85px; +} + +.role-name.frontend { + color: #FFC107; +} + +.role-name.backend { + color: #FF3E88; +} + +.role-name.pm { + color: #06D6A0; +} + +.level-badge { + padding: 5px 15px; + border-radius: 18px; + font-size: 13px; + font-weight: 700; + margin: 0 auto 0 8px; + min-width: 60px; + text-align: center; +} + +.level-badge.level-1 { + border-color: #93C5FD; + color: #1E40AF; +} + +.level-badge.level-2 { + border-color: #A78BFA; + color: #5B21B6; +} + +.level-badge.level-3 { + border-color: #F472B6; + color: #BE185D; +} + +.level-badge.level-4 { + border-color: #FBBF24; + color: #92400E; +} + +.level-badge.undiagnosed { + border-color: #E5E7EB; + color: #6B7280; +} + +.retest-btn { + padding: 7px 14px; + background-color: #6B8AFF; + color: white; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background-color 0.2s; + white-space: nowrap; +} + +.retest-btn:hover { + background-color: #5570E8; +} + +/* 반응형 개선 버전 (768px 이하 모바일) */ +@media (max-width: 768px) { + .profile-card { + display: flex; + flex-direction: column; + } + + /* 2. 각 영역의 순서를 지정*/ + .user-info-side { + display: flex; + flex-direction: column; + order: 1; /* 제일 먼저 */ + } + + .content-side { + order: 2; /* 기술스택, 프로젝트*/ + } + + .edit-button { + order: 3; /* 버튼을 맨 아래 */ + margin: 20px auto 40px; + max-width: 90%; + } + + + .my-levels-section { + order: 2; /* 정보 테이블 다음으로 */ + } + .info-table { + order: 1; + } + .mypage-wrapper { + padding: 20px 10px; + } + + .profile-card { + flex-direction: column; + padding-top: 120px; /* 배너 높이 조절 */ + } + + .profile-card::before { + height: 120px; + } + + /* 왼쪽 프로필 영역 */ + .user-info-side { + flex: none; + width: 100%; + padding: 60px 20px 30px; /* 패딩 최적화 */ + } + + .avatar-container { + top: -70px; + width: 130px; + height: 130px; + } + + .info-table { + margin-top: 30px; + gap: 15px; + } + + .info-row .label { + min-width: 70px; + font-size: 15px; + } + + .info-row .value { + font-size: 14px; + margin-left: 20px; + } + + /* 기술 스택 2줄 정렬 핵심 */ + .tags { + display: grid; + grid-template-columns: repeat(2, 1fr); /* 무조건 2열로 정렬 */ + gap: 10px; + } + + .tag-item { + padding: 10px 5px; + font-size: 14px; + text-align: center; /* 텍스트 중앙 정렬 */ + display: block; + white-space: nowrap; /* 글자 줄바꿈 방지 */ + overflow: hidden; + text-overflow: ellipsis; /* 너무 길면 ... 처리 */ + } + + /* 나의 레벨 섹션 깨짐 방지 */ + .my-levels-section { + margin-top: 40px; + padding: 15px; + } + + .level-item { + display: grid; /* flex에서 grid로 변경하여 영역 확보 */ + grid-template-columns: 80px 1fr 90px; /* 이름 / 배지 / 버튼 순서 고정 */ + align-items: center; + gap: 5px; + padding: 10px 0; + border-bottom: 1px solid #f3f4f6; + } + + .level-item:last-child { + border-bottom: none; + } + + .role-name { + font-size: 13px; + min-width: auto; + } + + .level-badge { + margin: 0; /* 자동 마진 제거 */ + padding: 4px 8px; + font-size: 12px; + width: fit-content; + justify-self: start; /* 왼쪽 정렬 */ + } + + .retest-btn { + padding: 6px 8px; + font-size: 11px; + width: 100%; + text-align: center; + } + + .content-side { + flex: none; + width: 100%; + padding: 10px 20px 40px; + } + + .tech-stack-box { + padding: 20px; + } + + .project-list-grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/static/css/onboarding_profile.css b/static/css/onboarding_profile.css new file mode 100644 index 0000000..a7124ec --- /dev/null +++ b/static/css/onboarding_profile.css @@ -0,0 +1,414 @@ +/* Onboarding Profile CSS - With Styled Tech Stacks */ + +/* CSS Variables */ +:root { + --primary-blue: #4169E1; + --primary-hover: #365AC3; + --light-blue: #C5D7F5; + --text-primary: #212529; + --text-secondary: #6C757D; + --border-color: #E5E8EB; + --background-gray: #F8F9FA; + --white: #FFFFFF; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +/* Page Title Styling */ +h1 { + text-align: center; + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 40px; + letter-spacing: -0.5px; +} + +/* Form Container - Creates the white card */ +form { + max-width: 600px; + margin: 0 auto; + background-color: var(--white); + border-radius: 20px; + padding: 48px 40px; + box-shadow: var(--shadow-md); + border: 1px solid var(--border-color); +} + +/* All form field containers */ +form > div { + margin-bottom: 32px; +} + +form > div:last-of-type { + margin-bottom: 40px; +} + +/* First div - Profile Image Section */ +form > div:first-child { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 40px; + position: relative; +} + +/* Hide the default label for profile image */ +form > div:first-child label { + display: none; +} + +/* Hide the actual file input */ +form > div:first-child input[type="file"] { + display: none; +} + +/* Profile Image Preview Container */ +form > div:first-child::before { + content: ''; + display: block; + width: 120px; + height: 120px; + border-radius: 50%; + background: linear-gradient(135deg, var(--light-blue) 0%, #B3CDFC 100%); + box-shadow: var(--shadow-md); + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + position: relative; + z-index: 1; +} + +form > div:first-child:hover::before { + transform: scale(1.02); + box-shadow: var(--shadow-lg); +} + +/* User icon - default state */ +form > div:first-child::after { + content: ''; + position: absolute; + top: 35px; + left: 50%; + transform: translateX(-50%); + width: 50px; + height: 50px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='rgba(255,255,255,0.7)' stroke-width='1.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + pointer-events: none; + z-index: 2; +} + +/* Camera icon button - positioned at bottom right */ +form > div:first-child { + padding-bottom: 20px; +} + +form > div:first-child label[for]:not([style*="display: none"])::after, +form > div:first-child > *:last-child::after { + content: ''; + position: absolute; + bottom: 20px; + right: calc(50% - 60px + 6px); + width: 36px; + height: 36px; + border-radius: 50%; + background-color: var(--white); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236C757D' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z'/%3E%3C/svg%3E"); + background-size: 18px; + background-position: center; + background-repeat: no-repeat; + border: 3px solid var(--white); + box-shadow: var(--shadow-md); + cursor: pointer; + transition: transform 0.2s; + z-index: 3; + pointer-events: auto; +} + +/* Make the entire profile image area clickable */ +form > div:first-child { + cursor: pointer; +} + +/* Add a class for when image is loaded */ +form > div:first-child.has-image::after { + display: none; +} + +form > div:first-child.has-image::before { + background-size: cover; + background-position: center; + background-repeat: no-repeat; + /* allow JS to set a custom profile image via CSS variable */ + background-image: var(--profile-url, linear-gradient(135deg, var(--light-blue) 0%, #B3CDFC 100%)); +} + +/* Second div - Nickname field with button */ +form > div:nth-child(2) { + position: relative; +} + +/* Nickname input styling */ +form > div:nth-child(2) input[type="text"] { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--border-color); + border-radius: 10px; + font-size: 15px; + background-color: var(--background-gray); + transition: all 0.2s; + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; +} + +form > div:nth-child(2) input[type="text"]:focus { + outline: none; + border-color: var(--primary-blue); + background-color: var(--white); + box-shadow: 0 0 0 3px rgba(65, 105, 225, 0.1); +} + +/* Labels */ +label { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +/* General Input Fields (GitHub ID) */ +input[type="text"] { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--border-color); + border-radius: 10px; + font-size: 15px; + background-color: var(--background-gray); + transition: all 0.2s; + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; +} + +input[type="text"]:focus { + outline: none; + border-color: var(--primary-blue); + background-color: var(--white); + box-shadow: 0 0 0 3px rgba(65, 105, 225, 0.1); +} + +/* Tech Stacks Container - NEW STYLING */ +form > div:last-of-type > div { + max-height: 300px !important; + overflow-y: auto !important; + border: 1px solid var(--border-color) !important; + padding: 16px !important; + border-radius: 10px !important; + background-color: var(--white) !important; + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; + align-content: flex-start; +} + +form > div:last-of-type > div::-webkit-scrollbar { + width: 8px; +} + +form > div:last-of-type > div::-webkit-scrollbar-track { + background: transparent; +} + +form > div:last-of-type > div::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +form > div:last-of-type > div::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Hide default select/checkbox display */ +form > div:last-of-type select { + display: none; +} + +/* Style checkboxes as chips/tags */ +form > div:last-of-type input[type="checkbox"] { + display: none; +} + +/* Checkbox labels styled as chips */ +form > div:last-of-type label { + display: inline-flex; + align-items: center; + padding: 8px 16px; + background-color: var(--background-gray); + border: 2px solid var(--border-color); + border-radius: 20px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + margin: 0; + user-select: none; +} + +form > div:last-of-type label:hover { + background-color: var(--light-blue); + border-color: var(--primary-blue); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +/* Checked state */ +/* Use :has to style the label when its contained input is checked (modern browsers) */ +form > div:last-of-type label:has(input[type="checkbox"]:checked) { + background-color: var(--primary-blue); + border-color: var(--primary-blue); + color: var(--white); + font-weight: 600; +} + +/* Add checkmark icon to checked items (when input is checked) */ +form > div:last-of-type label:has(input[type="checkbox"]:checked)::before { + content: '✓'; + margin-right: 6px; + font-weight: bold; +} + +/* JS fallback class when :has is unsupported */ +form > div:last-of-type label.is-checked { + background-color: var(--primary-blue); + border-color: var(--primary-blue); + color: var(--white); + font-weight: 600; +} + +form > div:last-of-type label.is-checked::before { + content: '✓'; + margin-right: 6px; + font-weight: bold; +} + +/* Help text for tech stacks */ +form > div:last-of-type small { + display: block; + margin-top: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +/* Error Messages */ +.errorlist { + list-style: none; + padding: 0; + margin: 0 0 8px 0; +} + +.errorlist li { + color: #DC3545; + font-size: 13px; + padding: 6px 12px; + background-color: #FFE5E8; + border-radius: 6px; + margin-bottom: 4px; +} + +/* Submit Button */ +button[type="submit"] { + width: 100%; + padding: 14px 24px; + background-color: var(--primary-blue); + color: var(--white); + border: none; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + box-shadow: var(--shadow-sm); + font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; + margin-top: 0; +} + +button[type="submit"]:hover { + background-color: var(--primary-hover); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +button[type="submit"]:active { + transform: translateY(0); +} + +/* Animation */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +form { + animation: fadeIn 0.4s ease-out; +} + +/* Responsive Design */ +@media (max-width: 768px) { + form { + padding: 32px 24px; + border-radius: 16px; + } + + h1 { + font-size: 20px; + margin-bottom: 32px; + } + + form > div:first-child::before { + width: 100px; + height: 100px; + } + + form > div:first-child::after { + width: 40px; + height: 40px; + top: 30px; + } + + form > div:nth-child(2) input[type="text"] { + padding-right: 16px; + } + + form > div:nth-child(2)::after { + position: relative; + display: block; + width: 100%; + margin-top: 8px; + text-align: center; + right: auto; + top: auto; + transform: none; + } + + form > div:last-of-type label { + font-size: 13px; + padding: 6px 12px; + } +} + +@media (max-width: 480px) { + form { + padding: 24px 20px; + } +} \ No newline at end of file diff --git a/static/css/passion_test.css b/static/css/passion_test.css new file mode 100644 index 0000000..abc4627 --- /dev/null +++ b/static/css/passion_test.css @@ -0,0 +1,46 @@ +/* 결과 화면 추가 스타일 */ +.passion-result-message { + font-size: 16px; + color: #666; + margin-bottom: 16px; +} + +/* 열정 레벨 테스트 헤더 */ + +.passion-title { + font-size: 28px; + font-weight: 700; + color: #1a1a1a; +} + +.passion-description { + font-size: 15px; + color: #666; + line-height: 1.8; +} + +/* 반응형 */ +@media (max-width: 768px) { + .passion-header { + margin-bottom: 32px; + } + + .passion-title { + font-size: 26px; + } + + .passion-description { + font-size: 14px; + line-height: 1.6; + } +} + +@media (max-width: 480px) { + .passion-title { + font-size: 24px; + } + + .passion-description { + font-size: 13px; + } +} diff --git a/static/css/password_reset.css b/static/css/password_reset.css new file mode 100644 index 0000000..8800c64 --- /dev/null +++ b/static/css/password_reset.css @@ -0,0 +1,351 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Apple SD Gothic Neo', sans-serif; + background-color: #f5f5f5; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 20px; + margin: 0; +} + +.container { + width: 100%; + max-width: 445px; +} + +.row { + width: 100%; +} + +.col-lg-6, +.col-md-8 { + width: 100%; +} + +.justify-content-center { + display: flex; + justify-content: center; +} + +/* 카드 스타일 */ +.auth-card { + background: white; + border-radius: 16px; + padding: 48px 40px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + width: 100%; +} + +/* 헤더 */ +.auth-header { + text-align: center; + margin-bottom: 32px; +} + +.auth-header h1 { + font-size: 24px; + font-weight: 600; + color: #333; + margin-bottom: 12px; +} + +.auth-header p { + font-size: 14px; + color: #666; + line-height: 1.5; +} + +/* 성공 카드 */ +.success-card { + text-align: center; +} + +.success-icon { + width: 80px; + height: 80px; + background: #22c55e; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 24px; + font-size: 48px; + color: white; + font-weight: bold; +} + +.success-card h1 { + font-size: 24px; + font-weight: 600; + color: #333; + margin-bottom: 12px; +} + +.success-card > p { + font-size: 14px; + color: #666; + margin-bottom: 24px; +} + +/* 폼 그룹 */ +.form-group { + margin-bottom: 20px; +} + +.form-label { + display: block; + font-size: 14px; + color: #666; + font-weight: 500; + margin-bottom: 8px; +} + +/* 입력 필드 */ +input[type="email"], +input[type="password"], +input[type="text"] { + width: 100%; + padding: 14px 16px; + border: 1px solid #e0e0e0; + border-radius: 8px; + font-size: 15px; + transition: all 0.3s ease; + background-color: #fafafa; +} + +input[type="email"]:focus, +input[type="password"]:focus, +input[type="text"]:focus { + outline: none; + border-color: #4285f4; + background-color: white; +} + +input[type="email"]::placeholder, +input[type="password"]::placeholder, +input[type="text"]::placeholder { + color: #999; +} + +/* 버튼 */ +.btn { + width: 100%; + padding: 14px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.3s ease; +} + +.btn-primary { + background: #4285f4; + color: white; +} + +.btn-primary:hover { + background: #3367d6; +} + +.btn-primary:active { + background: #2851a3; +} + +.btn-block { + width: 100%; + margin-top: 8px; +} + +.btn-back { + display: inline-block; + padding: 14px 32px; + background: #4285f4; + color: white; + text-decoration: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + margin-top: 24px; + transition: background 0.3s ease; +} + +.btn-back:hover { + background: #3367d6; +} + +/* 정보 박스 */ +.info-box { + background: #e3f2fd; + border-left: 4px solid #4285f4; + padding: 20px; + border-radius: 8px; + margin: 24px 0; + text-align: left; +} + +.info-box h3 { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 12px; +} + +.info-box p { + font-size: 14px; + color: #555; + margin: 0; +} + +.info-box ol { + margin: 0; + padding-left: 20px; +} + +.info-box ol li { + font-size: 14px; + color: #555; + margin-bottom: 8px; + line-height: 1.5; +} + +/* 경고 박스 */ +.warning-box { + background: #fffbeb; + border-left: 4px solid #f59e0b; + padding: 20px; + border-radius: 8px; + margin: 24px 0; + text-align: left; +} + +.warning-box strong { + font-size: 14px; + color: #333; + display: block; + margin-bottom: 8px; +} + +.warning-box ul { + margin: 0; + padding-left: 20px; +} + +.warning-box ul li { + font-size: 14px; + color: #555; + margin-bottom: 6px; + line-height: 1.5; +} + +.warning-box a { + color: #4285f4; + text-decoration: none; +} + +.warning-box a:hover { + text-decoration: underline; +} + +/* 비밀번호 힌트 */ +.password-hint { + background: #e3f2fd; + border-left: 4px solid #4285f4; + padding: 16px; + border-radius: 8px; + margin-top: 12px; +} + +.password-hint strong { + font-size: 14px; + color: #333; + display: block; + margin-bottom: 8px; +} + +.password-hint ul { + margin: 0; + padding-left: 20px; +} + +.password-hint ul li { + font-size: 13px; + color: #555; + margin-bottom: 4px; +} + +/* 에러 메시지 */ +.error-message { + color: #ef4444; + font-size: 13px; + margin-top: 6px; +} + +.alert { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 20px; + font-size: 14px; +} + +.alert-danger { + background: #fef2f2; + color: #ef4444; + border: 1px solid #fecaca; +} + +/* 푸터 */ +.auth-footer { + text-align: center; + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid #e0e0e0; +} + +.auth-footer p { + font-size: 14px; + color: #666; + margin-bottom: 8px; +} + +.auth-footer a { + color: #4285f4; + text-decoration: none; + transition: color 0.3s ease; +} + +.auth-footer a:hover { + color: #3367d6; + text-decoration: underline; +} + +/* 반응형 디자인 */ +@media (max-width: 480px) { + .auth-card { + padding: 32px 24px; + } + + .auth-header h1, + .success-card h1 { + font-size: 20px; + } + + .success-icon { + width: 64px; + height: 64px; + font-size: 36px; + } + + .info-box, + .warning-box, + .password-hint { + padding: 16px; + } +} \ No newline at end of file diff --git a/static/css/privacy-policy.css b/static/css/privacy-policy.css new file mode 100644 index 0000000..19c9d92 --- /dev/null +++ b/static/css/privacy-policy.css @@ -0,0 +1,51 @@ +.policy { + width: 80%; + margin: 0 auto 200px; +} + +.policy > h1 { + text-align: center; + color: #4272EF; + margin: 100px auto; + font-size: 35px; +} + +.policy h3 { + margin: 10px 0; +} + +.policy p { + color: #333; + font-size: 15px; + margin-top: 10px; +} + +.notice { + display: block; + font-weight: 550; + color: #000; + margin: 3px 0; +} + +.policy a { + display: block; + margin: 50px auto; + padding: 10px 0; + text-align: center; + text-decoration: none; + width: 150px; + background: #4272EF; + color: #fff; + border-radius: 20px; +} + +.policy a:hover { + background: #1F4CC0; + transition: 0.5s ease; +} + +@media (max-width: 430px) { + .policy > h1 { + font-size: 28px; + } +} \ No newline at end of file diff --git a/static/css/profile_edit.css b/static/css/profile_edit.css new file mode 100644 index 0000000..f9743f5 --- /dev/null +++ b/static/css/profile_edit.css @@ -0,0 +1,419 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Apple SD Gothic Neo', sans-serif; + background-color: #f5f5f5; + min-height: 100vh; + padding: 40px 20px; +} + +.profile-edit-wrapper { + max-width: 1200px; + margin: 0 auto; +} + +.edit-card { + background: white; + border-radius: 20px; + padding: 60px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + display: grid; + grid-template-columns: 400px 1fr; + gap: 80px; +} + +/* 왼쪽: 사용자 정보 */ +.user-info-side { + display: flex; + flex-direction: column; + align-items: center; +} + +/* 프로필 이미지 */ +.avatar-container { + position: relative; + width: 180px; + height: 180px; + margin-bottom: 20px; +} + +.profile-img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.edit-badge { + position: absolute; + bottom: 0; + right: 0; + width: 44px; + height: 44px; + background: #333; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease; +} + +.edit-badge:hover { + transform: scale(1.1); +} + +.edit-badge img { + width: 20px; + height: 20px; + filter: invert(1); +} + +/* 레벨 태그 */ +.level-tag { + background: #667eea; + color: white; + padding: 8px 24px; + border-radius: 20px; + font-size: 16px; + font-weight: 600; + margin-bottom: 40px; +} + +/* 폼 */ +.edit-form { + width: 100%; +} + +/* 정보 테이블 */ +.info-table { + width: 100%; + margin-bottom: 30px; +} + +.info-row { + display: flex; + flex-direction: column; + margin-bottom: 24px; +} + +.label { + font-size: 15px; + font-weight: 600; + color: #333; + margin-bottom: 10px; +} + +.input-field { + width: 100%; + padding: 14px 18px; + border: none; + background-color: #f0f2f7; + border-radius: 12px; + font-size: 15px; + color: #666; + transition: all 0.3s ease; +} + +.input-field:focus { + outline: none; + background-color: #e8eaf0; +} + +.input-field.readonly { + background-color: #f0f2f7; + color: #999; + cursor: not-allowed; +} + +/* 닉네임 중복확인 */ +.nickname-check-wrapper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.input-button-group { + display: flex; + gap: 10px; + align-items: center; +} + +.input-button-group input { + flex: 1; + padding: 14px 18px; + border: none; + background-color: #f0f2f7; + border-radius: 12px; + font-size: 15px; + color: #333; + transition: all 0.3s ease; +} + +.input-button-group input:focus { + outline: none; + background-color: #e8eaf0; +} + +.input-button-group input.valid { + background-color: #f0fdf4; + border: 2px solid #22c55e; +} + +.input-button-group input.invalid { + background-color: #fef2f2; + border: 2px solid #ef4444; +} + +.check-btn { + padding: 14px 24px; + background-color: #667eea; + color: white; + border: none; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.3s ease; + white-space: nowrap; + flex-shrink: 0; +} + +.check-btn:hover { + background-color: #5568d3; +} + +.check-status { + font-size: 13px; +} + +.check-status.success { + color: #22c55e; +} + +.check-status.error { + color: #ef4444; +} + +/* 에러 메시지 */ +.error-messages { + margin-bottom: 20px; +} + +.error-text { + color: #ef4444; + font-size: 14px; + margin-bottom: 8px; +} + +/* 수정 완료 버튼 */ +.save-button { + width: 100%; + padding: 16px; + background: #4285f4; + color: white; + border: none; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.3s ease; + margin-top: 10px; +} + +.save-button:hover { + background: #3367d6; +} + +.save-button:active { + background: #2851a3; +} + +/* 오른쪽: 기술 스택 */ +.content-side { + display: flex; + flex-direction: column; +} + +.tech-stack-box { + background: #f8f9fa; + border: 2px solid #e9ecef; + border-radius: 20px; + padding: 40px; + min-height: 400px; +} + +.side-title { + font-size: 20px; + font-weight: 700; + color: #333; + text-align: center; + margin-bottom: 30px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.tag-item { + background: white; + border: 2px solid #e9ecef; + border-radius: 20px; + padding: 10px 20px; + font-size: 15px; + font-weight: 600; + transition: all 0.3s ease; +} + +/* 기술 스택 카테고리별 색상 */ +.tag-frontend { + color: #FFC107; + border-color: #FFC107; +} + +.tag-backend { + color: #FF3E88; + border-color: #FF3E88; +} + +.tag-pm { + color: #06D6A0; + border-color: #06D6A0; +} + +.empty-msg { + text-align: center; + color: #999; + font-size: 14px; + padding: 40px 0; +} + +/* Tech stack form field styling */ +.info-row div[style*="max-height"] { + max-height: 300px; + overflow-y: auto; + border: 1px solid #ccc; + padding: 10px;} + +.info-row div[style*="max-height"] > ul, +.info-row div[style*="max-height"] > fieldset { + display: flex; + flex-wrap: wrap; + gap: 16px; /* 원하는 간격 */ + list-style: none; + padding: 0; + margin: 0; +} + +.info-row div[style*="max-height"] input[type="checkbox"] { + display: none; + gap: 5px; +} + +.info-row div[style*="max-height"] label { + display: inline-flex; + align-items: center; + padding: 8px 16px; + background-color: #f0f2f7; + border: 2px solid #e9ecef; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + color: #333; + cursor: pointer; + transition: all 0.2s; + margin: 5px; + user-select: none; +} + +.info-row div[style*="max-height"] label:hover { + background-color: #e8eaf0; + border-color: #667eea; + transform: translateY(-1px); + gap: 5px; +} + +.info-row div[style*="max-height"] label.is-checked { + background-color: #667eea; + border-color: #667eea; + color: white; + font-weight: 600; + gap: 5px; +} + +.info-row div[style*="max-height"] label.is-checked::before { + content: '✓'; + margin-right: 6px; + font-weight: bold; + gap: 5px; +} + +/* 반응형 디자인 */ +@media (max-width: 1024px) { + .edit-card { + grid-template-columns: 1fr; + gap: 40px; + padding: 40px; + } +} + +@media (max-width: 768px) { + .edit-card { + padding: 30px 20px; + } + + .avatar-container { + width: 140px; + height: 140px; + } + + .edit-badge { + width: 36px; + height: 36px; + } + + .edit-badge img { + width: 16px; + height: 16px; + } + + .tech-stack-box { + padding: 30px 20px; + } +} + +@media (max-width: 480px) { + body { + padding: 20px 10px; + } + + .edit-card { + padding: 20px 15px; + } + + .avatar-container { + width: 120px; + height: 120px; + } + + .level-tag { + font-size: 14px; + padding: 6px 20px; + } + + .side-title { + font-size: 18px; + } + + .tag-item { + font-size: 14px; + padding: 8px 16px; + } +} \ No newline at end of file diff --git a/static/css/reflections.css b/static/css/reflections.css new file mode 100644 index 0000000..0558fe0 --- /dev/null +++ b/static/css/reflections.css @@ -0,0 +1,1921 @@ +/* reflections.css (note_list) */ + +/* ========================= + Color Tokens (피그마 제공 + 그레이스케일 추출/근사) + - 제공한 Blue/Role 색상은 그대로 + - 텍스트 그레이는 피그마 스샷 기준으로 "어두운 제목/중간 본문/연한 날짜" 톤으로 맞춤 +========================= */ +:root { + /* Blue scale */ + --blue-0: #F6F8FF; + --blue-5: #EAF0FF; + --blue-10: #CAD9FF; + --blue-20: #A4BDFD; + --blue-40: #4272EF; + --blue-50: #1F4CC0; + --blue-70: #1D294B; + + /* Role colors */ + --pm: #00B9B0; + --pm-sub: #37D3BF; + --backend: #FF3E88; + --backend-sub: #FF69A4; + --frontend: #FFCE53; + --frontend-sub: #FFDF6E; + + /* UI states */ + --heart: #F24C4C; + --danger: #F15C5C; + + /* Grayscale (피그마 텍스트 톤 맞춤) */ + --gray-900: #2B2F3A; /* 제목급(거의 블랙) */ + --gray-700: #4B5563; /* 본문 1 */ + --gray-600: #6B7280; /* 본문 2 */ + --gray-500: #8B95A1; /* 날짜/보조 */ + --gray-300: #D7DDE8; /* 경계 */ +} + +body { + background: #F9F9F9; +} + +button { + cursor: pointer; +} + +/* 토스트 */ + +/* 토스트 (테마 맞춘 밝은 카드형 + 상단 슬라이드) */ +.ref-toast { + position: fixed; + top: 72px; + left: 50%; + transform: translateX(-50%) translateY(-30px) scale(0.95); + background: #fff; + color: var(--gray-900); + padding: 12px 16px; + + border-radius: 9999px; + font-size: 15px; + font-weight: 600; + + opacity: 0; + z-index: 9999; + + border: 1px solid var(--blue-10); + box-shadow: 0 12px 30px rgba(29, 41, 75, 0.15); + min-width: 220px; + max-width: min(560px, calc(100vw - 24px)); + text-align: center; + + transition: + transform 0.35s cubic-bezier(.22,1.4,.36,1), + opacity 0.25s ease; +} + +.ref-toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); +} + + + +/* 성공: 블루 포인트 */ +.ref-toast-success { + border-color: var(--blue-20); +} +.ref-toast-success::before { + content: "✓ "; + font-weight: 800; + font-size: 15px; + padding-left: 5px; +} + +/* 실패: 빨간 포인트 */ +.ref-toast-error { + border-color: #fca5a5; +} + + +/* ========================= + Note List Page +========================= */ +.ref-page { + background: #F9F9F9; + padding: 32px 0 96px; +} + +.ref-controls, +.ref-list { + max-width: 80%; + margin: 0 auto; + padding: 0 16px; +} + +/* ========================= + Search (피그마처럼 길게, 버튼은 오른쪽 pill) +========================= */ +.ref-controls { + margin-bottom: 18px; +} + +.ref-search { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ref-searchbar { + display: flex; + align-items: center; + gap: 10px; + background: #fff; + border-radius: 999px; + padding: 5px 10px 5px 15px; + box-shadow: 0 0 0 1px var(--blue-5); +} + +.ref-searchbar:focus-within { + outline: 1px solid var(--blue-40); +} + +.ref-searchicon { + width: 30px; height: 30px; + align-items: center; +} + +.ref-searchicon > img { + width: 100%; +} + +.ref-searchinput { + flex: 1; + border: 0; + outline: 0; + font-size: 16px; + color: var(--gray-900); +} + +.ref-searchinput::placeholder { + color: var(--gray-500); +} + +.ref-searchbtn { + border: 0; + cursor: pointer; + border-radius: 999px; + padding: 10px 18px; + font-size: 14px; + font-weight: 500; + background: var(--blue-70); + color: #fff; +} + +.ref-searchbtn:hover { + background: var(--blue-50); + transition: 0.5s ease; +} + +/* ========================= + Filter row (왼쪽 2개, 오른쪽 글쓰기) +========================= */ +.ref-filters { + display: flex; + align-items: center; + gap: 12px; +} + +.ref-select-container { + position: relative; + display: inline-flex; + align-items: center; + min-width: 0; +} + +.ref-selectbox { + appearance: none; + border: 1px solid var(--blue-10); + background: #fff; + border-radius: 999px; + padding: 10px 45px 10px 18px; + font-size: 14px; + font-weight: 500; + color: var(--gray-900); + min-width: 0px; + width: 100%; +} + +.ref-selectbox:focus-within { + outline: none; + border: 1px solid var(--blue-40); +} + +.ref-selectchev { + position: absolute; + right: 14px; + color: var(--blue-40); + pointer-events: none; + display: inline-flex; + align-items: center; +} + +.ref-action-btn{ + display:inline-flex; + align-items:center; + gap:8px; + padding:10px 16px; + border-radius:999px; + border:1px solid var(--blue-10); + background:#fff; + font-size:14px; + cursor:pointer; + text-decoration:none; +} + +.ref-action-primary{ + background:#5b6ee1; + color:#fff; + border:0; +} + +.ref-action-icon{ + width:18px; + height:18px; + object-fit:contain; +} + +.ref-filters-actions{ + display: flex; /* 핵심 */ + align-items: center; + gap: 12px; + flex: 1; /* 오른쪽 끝까지 영역 확장 */ + min-width: 0; + flex-wrap: nowrap; +} + +.ref-writebtn { + margin-left: auto; + text-decoration: none; + text-align: center; + border-radius: 999px; + width: auto; + white-space: nowrap; /* 글씨 줄바꿈 방지 */ + padding: 10px 30px; + font-size: 14px; + font-weight: 600; + background: var(--blue-40); + color: #fff; +} + +.ref-writebtn:hover { + background: var(--blue-50); + transition: 0.5s ease-in-out; +} + +.bookmark-filter-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 45px 10px 18px; + border-radius: 999px; + background: #ffffff; + color: var(--gray-800); + border: 1px solid var(--blue-10); + cursor: pointer; + font-size: 14px; + font-weight: 500; +} + +.bookmark-filter-btn .bookmark-icon { + width: 16px; + height: 16px; + fill: currentColor; +} + +/* ON 상태 */ +.bookmark-filter-btn.is-active { + border: 1.5px solid var(--blue-40); +} + +.bookmark-filter-btn.is-active .bookmark-icon { color: var(--blue-40); +} + +/* @media (max-width: 768px){ + + .ref-action-btn{ + width:42px; + height:42px; + padding:0; + justify-content:center; + border-radius:50%; + } + + .ref-action-txt{ + display:none; + } + + .ref-action-icon{ + width:20px; + height:20px; + } +} */ + +/* ========================= + List / Card (피그마처럼 넓고 둥글고, 테두리+은은한 그림자) +========================= */ +.ref-list { + display: flex; + flex-direction: column; + gap: 18px; +} + +.note-card { + position: relative; + background: #fff; + border-radius: 22px; + padding: 22px 22px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + align-items: flex-start; + gap: 14px; +} + +.note-main { + flex: 1; + text-decoration: none; + color: inherit; + min-width: 0; +} + +/* 북마크(하트 대신) */ +.bookmark-btn { + border: 0; + background: transparent; + cursor: pointer; + padding: 2px; + color: var(--gray-300); /* 기본은 연한 그레이 */ + line-height: 0; +} + +.bookmark-btn.is-active .bookmark-icon{ + color: var(--blue-40); +} + +.bookmark-icon { + width: 20px; + height: 20px; + display: block; + fill: currentColor; + color: var(--gray-500) +} + +.bookmark-on { + display: none; +} + +.bookmark-btn.is-active .bookmark-on { + display: block; +} + +.bookmark-btn.is-active .bookmark-off { + display: none; +} + + +/* 제목 줄 */ +.note-topline { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.note-title { + font-size: 20px; /* 피그마처럼 큼 */ + font-weight: 700; + color: var(--gray-900); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 태그 pill */ +.note-tag { + font-size: 12px; + font-weight: 700; + padding: 6px 12px; + border-radius: 999px; + white-space: nowrap; + border: 1px solid transparent; +} + +/* 역할별 태그(피그마처럼 라인+연한 배경 느낌) */ +.tag-pm { + background: color-mix(in srgb, var(--pm-sub) 18%, #fff); + border-color: color-mix(in srgb, var(--pm-sub) 55%, #fff); + color: var(--pm); +} + +.tag-backend { + background: color-mix(in srgb, var(--backend-sub) 18%, #fff); + border-color: color-mix(in srgb, var(--backend-sub) 55%, #fff); + color: var(--backend); +} + +.tag-frontend { + background: color-mix(in srgb, var(--frontend-sub) 22%, #fff); + border-color: color-mix(in srgb, var(--frontend-sub) 60%, #fff); + color: #B88700; +} + +/* 본문 프리뷰 */ +.note-preview { + margin: 10px 0 0; + font-size: 15px; + font-weight: 400; + line-height: 1.4; + color: var(--gray-600); + display: -webkit-box; + + /* 표준 (린터용, 미래 대비) */ + line-clamp: 2; + + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* 날짜 */ +.note-date { + display: inline-block; + margin-top: 10px; + font-size: 14px; + font-weight: 400; + color: var(--gray-500); +} + +/* ========================= + More menu (오른쪽 케밥 + 드롭다운) +========================= */ +.note-morewrap { + position: relative; + margin-left: 6px; +} + +.note-morebtn { + border: 0; + background: transparent; + cursor: pointer; + padding: 6px; + border-radius: 10px; + color: var(--blue-70); +} + +.note-menu { + position: absolute; + right: 0; + top: 38px; + width: 120px; + background: #fff; + border-radius: 18px; + box-shadow: 0 10px 24px rgba(29, 41, 75, 0.12); + overflow: hidden; + z-index: 20; +} + +.note-menuitem { + display: block; + width: 100%; height: 50%; + padding: 10px 14px; + font-size: 14px; + font-weight: 400; + letter-spacing: -0.02em; /* 한글 크게 보이는 느낌 보정 */ + color: var(--blue-40); /* 피그마처럼 수정은 블루 계열 */ + text-decoration: none; + background: #fff; + border: 0; + text-align: left; + cursor: pointer; +} + +.note-menuitem:hover { + background: var(--blue-5); +} + +.note-menuitem.danger { + color: var(--danger); +} + +.note-menuform { + margin: 0; +} + +/* ========================= + Empty +========================= */ +.ref-empty { + text-align: center; + padding: 84px 0; +} + +.ref-empty-title { + color: var(--gray-700); + font-weight: 600; + font-size: 30px; + margin: 0 0 14px; +} + +.ref-empty-cta { + display: inline-block; + text-decoration: none; + border-radius: 999px; + padding: 10px 25px; + background: var(--blue-40); + color: #fff; + font-weight: 500; +} + +.ref-empty-cta:hover { + background: var(--blue-50); + transition: 0.5s ease; +} + +/* ========================= + Note Form (create/update) +========================= */ +.ref-form-wrap { + max-width: 1020px; + margin: 32px auto 120px; + padding: 44px 44px 34px; + background: #fff; + border-radius: 26px; + box-shadow: 0 10px 30px rgba(29, 41, 75, 0.08); +} + +.ref-alert { + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid #fca5a5; + background: #fef2f2; + border-radius: 12px; + font-weight: 700; +} + +.ref-form-head { + display: flex; + align-items: center; + gap: 18px; +} + +.ref-title { + flex: 1; + border: 0; + outline: 0; + font-size: 40px; + font-weight: 700; + color: var(--gray-900); + padding: 8px 0; +} + +.ref-head-right { + display: flex; + align-items: center; + gap: 12px; +} + +.ref-divider { + height: 2px; + background: var(--blue-20); + opacity: 0.5; + margin: 18px 0 26px; + border-radius: 999px; +} + +/* meta */ +.ref-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px 22px; + margin-bottom: 22px; +} + +.ref-meta-row { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ref-label { + font-size: 15px; + font-weight: 550; + color: var(--gray-700); + margin-left: 5px; +} + +.ref-select { + width: 100%; + appearance: none; + border: 1px solid var(--blue-20); + background: #fff; + border-radius: 14px; + padding: 12px 14px; + font-size: 14px; + font-weight: 500; + color: var(--gray-900); +} + +.ref-select:focus-within { + outline: none; + border: 1px solid var(--blue-40); +} + +.ref-selectwrap{ + position: relative; +} + +.ref-form-selectchev { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; /* 클릭은 select로 */ + display: inline-flex; + align-items: center; + justify-content: center; + + width: 28px; + height: 28px; + border-radius: 10px; + + color: var(--blue-50); /* 필요하면 바꾸세요 */ + opacity: 0.9; +} + +.ref-form-selectchev svg { + width: 18px; + height: 18px; + display: block; +} + +.ref-meta-hint { + font-size: 12px; + color: var(--gray-500); + font-weight: 500; + margin-left: 10px; +} + +/* questions */ +.ref-q-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.ref-q-card { + border: 1px solid var(--blue-5); + border-radius: 18px; + overflow: hidden; + background: #fff; +} + +.ref-q-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 8px 16px; + background: color-mix(in srgb, var(--blue-5) 80%, #fff); +} + +.ref-q-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 650; + color: var(--gray-900); +} + +.ref-q-num { + flex: 0 0 22px; + width: 22px; + height: 22px; + min-width: 22px; + min-height: 22px; + aspect-ratio: 1 / 1; + + /* 원 스타일 */ + border-radius: 50%; + background: #2f2f2f; /* 검은 원 */ + color: #ffffff; /* 흰 숫자 */ + + /* 가운데 정렬 */ + display: inline-flex; + align-items: center; + justify-content: center; + + /* 숫자 안정화 */ + font-size: 12px; + font-weight: 650; +} + +.ref-q-actions { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.ref-iconbtn { + border: 0; + background: transparent; + cursor: pointer; + padding: 6px; + border-radius: 12px; + color: var(--gray-500); +} + +.ref-iconbtn:hover { + background: var(--blue-5); +} + +.ref-iconbtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ref-q-body { + padding: 16px; +} + +.ref-textarea { + width: 100%; + border: 0; + outline: 0; + resize: vertical; + min-height: 140px; + font-size: 14.5px; + line-height: 1.55; + color: var(--gray-700); +} + +/* assets */ +.ref-assets { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--blue-5); +} + +.ref-hidden-file { + display: none !important; +} + +.ref-assets-title { + font-weight: 900; + color: var(--gray-900); + margin-bottom: 10px; +} + +.ref-assets-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.ref-asset { + border: 1px solid var(--blue-5); + border-radius: 14px; + overflow: hidden; + background: #fff; +} + +.ref-asset-img { + width: 100%; + height: 140px; + object-fit: cover; + display: block; +} + +.ref-asset-del { + width: 100%; + padding: 10px 12px; + border: 0; + background: #fff; + cursor: pointer; + font-weight: 900; + color: var(--danger); +} + +/* bottom actions */ +.ref-form-actions { + margin-top: 22px; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.ref-btn { + border-radius: 30px; + padding: 10px 18px; + font-weight: 500; + font-size: 14px; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +button.ref-btn{ + appearance: none; + -webkit-appearance: none; + border: 0; + font: inherit; + font-weight: 900; + /* color: inherit; */ + background: transparent; +} + + + +.ref-btn-ghost { + border: 1px solid var(--blue-20); + background: #fff; + color: var(--gray-900); +} + +.ref-btn.ref-btn-primary { + font-weight: 500; + font-size: 14px; + border: 0; + background: var(--blue-40); + color: #fff; +} + +/* 테이블 삽입 modal */ +.ref-table-picker-backdrop{ + position: fixed; inset: 0; + background: rgba(0,0,0,.4); + z-index: 1000; +} + +/* hidden 아닐 때만 표시 */ +.ref-table-picker-backdrop:not([hidden]){ + display: flex; + align-items: center; + justify-content: center; +} + + +.ref-table-picker-modal{ + background:#fff; + border-radius:16px; + padding:18px; + width:230px; +} + +.ref-table-picker-title{ + font-weight:900; + margin-bottom:12px; +} + +.ref-table-picker-grid{ + aspect-ratio: 1 / 1; + display:grid; + grid-template-columns:repeat(5,1fr); + gap:4px; + margin-bottom:12px; +} + +.ref-table-cell{ + width:36px; height:36px; + border:1px solid #d1d5db; + border-radius: 8px; + cursor:pointer; +} + +.ref-table-cell.active{ + background:#2563eb; +} + +.ref-table-picker-footer{ + display:flex; + justify-content:space-between; + align-items:center; +} + +.ref-table-picker-cancel{ + border:0; + background:transparent; + color:var(--gray-600); + font-weight:700; + cursor:pointer; +} + +/* ========================= + Markdown-like Table (Notion/GitHub-ish) + ========================= */ + +/* ===== table ===== */ + +.md-table-wrap{ + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +/* 표는 그대로 border 유지 */ +.md-table{ + border-collapse: collapse; + border-spacing: 0; + table-layout: auto; /* fixed 제거 */ + font-size: 14px; + line-height: 1.6; + color: #111827; + background: #fff; + + width: max-content; /* 내용만큼 */ + border: 1px solid #e5e7eb; +} + + +.md-thead{ background:#f9fafb; } +.md-th,.md-td{ + padding: 10px 12px; + border: 1px solid #e5e7eb; + vertical-align:top; + word-break:break-word; +} +.md-th{ font-weight:600; } +.md-tr:last-child .md-td{ border-bottom:0; } +.md-tr .md-th:last-child, +.md-tr .md-td:last-child{ border-right:0; } + +.md-tbody .md-tr:nth-child(even){ background:#fcfcfd; } +.md-tbody .md-tr:hover{ background:#f3f4f6; } + +/* ===== image ===== */ +.md-img{ + display:block; + max-width:100%; + height:auto; + max-height:420px; /* 여기로 “일정 크기 이하” 제한 */ + object-fit:contain; + border:1px solid #e5e7eb; + border-radius:12px; + background:#fff; +} + +/* (선택) markdown 기본 요소도 깔끔하게 */ +.md-pre{ + padding:12px; + border:1px solid #e5e7eb; + border-radius:12px; + overflow:auto; + background:#0b1020; +} +.md-code{ + font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size:13px; +} +.md-quote{ + margin:10px 0; + padding:10px 12px; + border-left:4px solid #e5e7eb; + background:#f9fafb; + border-radius:10px; +} +.md-p{ margin:8px 0; } +.md-ul,.md-ol{ padding-left:20px; margin:8px 0; } +.md-li{ margin:4px 0; } + + + +/* ========================= + Note Detail +========================= */ +.ref-detail-wrap { + max-width: 1020px; + margin: 32px auto 120px; + padding: 44px 44px 34px; + background: #fff; + border-radius: 26px; + box-shadow: 0 10px 30px rgba(29, 41, 75, 0.08); +} + +/* 상단 바 */ +.ref-detail-topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.ref-detail-title-section { + flex: 1; + min-width: 0; /* 텍스트 overflow 방지 */ +} + +.ref-detail-title { + font-size: 40px; + font-weight: 700; + line-height: 1.2; + color: var(--gray-900); + margin-bottom: 30px; +} + +.ref-detail-meta-text { + margin: 20px 5px; + font-size: 12px; + color: var(--gray-600); +} + +.ref-detail-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + position: relative; + flex-shrink: 0; +} + + +/* ensure absolute dropdowns (note-menu) position relative to this actions container */ +.ref-detail-actions { + position: relative; +} + +/* 메타 카드 */ +.ref-detail-meta-card { + border: 1px solid #e5e7eb; + border-radius: 18px; + overflow: hidden; + background: #fff; +} + +.ref-detail-meta-tags { + padding: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.ref-meta-tag { + font-size: 12px; + padding: 6px 10px; + border-radius: 999px; + background: #f3f4f6; + color: #374151; + font-weight: 600; +} + +.ref-meta-tag-project { + background: #eef2ff; + color: #3730a3; +} + +.ref-meta-tag-personal { + background: #ecfeff; + color: #155e75; +} + +/* 질문 리스트 */ +.ref-detail-qa-list { + display: flex; + flex-direction: column; + gap: 18px; +} + +.ref-detail-qa-card { + border: 1px solid #e5e7eb; + border-radius: 18px; + overflow: hidden; + background: #fff; +} + +.ref-detail-qa-header { + background: #eef2ff; + padding: 12px 14px; + font-weight: 700; + color: var(--gray-900); + display: flex; + align-items: center; + gap: 10px; +} + +.ref-detail-qa-title { + flex: 1; +} + +.ref-detail-qa-body { + padding: 14px; + font-size: 16px; + line-height: 1.7; + color: var(--gray-700); +} + +/* 마크다운 내보내기 */ +.ref-detail-export { + border: 1px solid #e5e7eb; + border-radius: 18px; + background: #fff; + overflow: hidden; + margin-top: 50px; +} + +.ref-detail-export-summary { + cursor: pointer; + padding: 12px 14px; + font-weight: 700; + background: #f3f4f6; + color: var(--gray-900); + user-select: none; +} + +.ref-detail-export-summary:hover { + background: #e5e7eb; +} + +.ref-detail-export-body { + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.ref-detail-export-textarea { + width: 100%; + resize: vertical; + border: 1px solid #e5e7eb; + border-radius: 14px; + padding: 12px; + outline: none; + background: #f9fafb; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 13px; + line-height: 1.5; + color: var(--gray-900); +} + +.ref-detail-export-textarea:focus { + border-color: var(--blue-40); + background: #fff; +} + +/* 반응형 */ + +/* note_detail 반응형 */ +/* ===== 노트북 (1024px ~ 1200px) ===== */ +@media (max-width: 1200px) { + .ref-detail-wrap { + max-width: 90%; + padding: 40px 36px 30px; + } + + .ref-detail-title { + font-size: 36px; + margin-bottom: 24px; + } +} + +/* ===== 태블릿 (768px ~ 1023px) ===== */ +@media (max-width: 1023px) { + .ref-detail-wrap { + max-width: 95%; + padding: 36px 32px 28px; + } + + .ref-detail-title { + font-size: 32px; + margin-bottom: 20px; + } + + .ref-detail-actions { + flex-wrap: nowrap; /* 한 줄 유지 */ + gap: 6px; + } + + .ref-detail-qa-body { + font-size: 15px; + } +} + +/* ===== 모바일 (480px ~ 767px) ===== */ +@media (max-width: 767px) { + .ref-detail-wrap { + max-width: 100%; + padding: 28px 20px 24px; + border-radius: 20px; + margin: 20px auto 80px; + } + + .ref-detail-topbar { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .ref-detail-title { + font-size: 24px; + margin-bottom: 12px; + } + + .ref-detail-meta-text { + font-size: 11px; + margin: 12px 10px; + text-align: right; + } + + .ref-detail-actions { + justify-content: flex-end; /* 오른쪽 정렬 */ + flex-wrap: wrap; + gap: 8px; + } + + /* 질문 카드 */ + .ref-detail-qa-header { + padding: 12px 14px; + flex-wrap: wrap; + gap: 8px; + } + + .ref-detail-qa-title { + font-size: 14px; + line-height: 1.4; + word-break: keep-all; + } + + .ref-detail-qa-body { + padding: 12px; + font-size: 14px; + } + + /* 마크다운 내보내기 */ + .ref-detail-export { + margin-top: 40px; + } + + .ref-detail-export-summary { + padding: 10px 12px; + font-size: 14px; + } + + .ref-detail-export-textarea { + font-size: 12px; + } +} + +/* ===== 소형 모바일 (480px 이하) ===== */ +@media (max-width: 480px) { + .ref-detail-wrap { + padding: 24px 16px 20px; + border-radius: 16px; + } + + .ref-detail-title { + font-size: 20px; + } + + .ref-detail-meta-text { + font-size: 10px; + } + + .ref-detail-actions { + gap: 6px; + } + + .ref-btn { + padding: 8px 14px; + font-size: 13px; + } + + .ref-detail-qa-header { + padding: 10px 12px; + } + + .ref-detail-qa-title { + font-size: 13px; + } + + .ref-detail-qa-body { + padding: 10px; + font-size: 13px; + } + + .ref-q-num { + width: 20px; + height: 20px; + font-size: 11px; + } +} + +/* note_list 반응형 */ +/* ===== 노트북 (1024px ~ 1200px) ===== */ +@media (max-width: 1200px) { + .ref-controls, + .ref-list { + max-width: 85%; + } +} + +/* ===== 태블릿 (768px ~ 1023px) ===== */ +@media (max-width: 1023px) { + .ref-controls, + .ref-list { + max-width: 90%; + } + + .ref-searchbar { + padding: 5px 8px 5px 12px; + } + + .ref-searchicon { + width: 26px; + height: 26px; + } + + .ref-searchinput { + font-size: 15px; + } + + .ref-searchbtn { + padding: 9px 16px; + font-size: 13px; + } + + .ref-selectbox { + font-size: 14px; + padding: 9px 40px 9px 16px; + } + + .bookmark-filter-btn { + padding: 9px 18px 9px 16px; + font-size: 13px; + } + + .ref-writebtn { + padding: 9px 20px; + font-size: 13px; + } + + .note-title { + font-size: 18px; + } + + .note-preview { + font-size: 14px; + } +} + +/* ===== 모바일 (480px ~ 767px) ===== */ +@media (max-width: 767px) { + .ref-controls, + .ref-list { + max-width: 95%; + padding: 0 12px; + } + + .ref-searchbar { + padding: 4px 6px 4px 10px; + } + + .ref-searchicon { + width: 24px; + height: 24px; + } + + .ref-searchinput { + font-size: 14px; + } + + .ref-searchbtn { + padding: 8px 14px; + font-size: 12px; + } + + /* 1) 필터 한 줄 + 2영역(셀렉트 / 액션) */ + .ref-filters{ + display:flex; + flex-wrap: nowrap; + align-items:center; + gap: 10px; + } + + /* 2) 셀렉트 2개가 남은 영역 1:1 */ + .ref-filters-selects{ + flex: 1; + display:flex; + gap: 8px; + min-width: 0; + } + .ref-filters-selects .ref-select-container{ + flex: 1; + min-width: 0; + } + .ref-filters-selects .ref-selectbox{ + width: 100%; + min-width: 0; + } + + /* 3) 오른쪽 원형 2개 고정 */ + .ref-filters-actions{ + /* margin-left: auto; */ + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; + } + + /* 4) 북마크/글쓰기: 원형 버튼으로 강제(기존 pill/width/flex 전부 씹어먹음) */ + .ref-filters-actions .bookmark-filter-btn, + .ref-filters-actions .ref-writebtn{ + box-sizing: border-box; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + padding: 0 !important; + border-radius: 50%; + display: inline-flex !important; + align-items: center; + justify-content: center; + line-height: 1; + flex: 0 0 40px !important; + white-space: nowrap; + } + + /* 5) 모바일에서는 텍스트 숨김 */ + .ref-filters-actions .ref-action-txt{ + display:none !important; + } + + /* 6) 아이콘 정렬/크기 고정 (svg/img baseline 제거) */ + .ref-filters-actions .ref-action-icon{ + width: 20px; + height: 20px; + display:block; + object-fit: contain; + } + + /* 7) 글쓰기 버튼 색 유지 */ + .ref-filters-actions .ref-writebtn{ + background: var(--blue-40); + border: 0; + color: #fff; + text-decoration:none; + } + + + .ref-selectchev { + right: 10px; + } + + .bookmark-filter-btn .bookmark-icon { + width: 14px; + height: 14px; + } + + /* 카드 레이아웃 */ + .note-card { + flex-direction: column; + padding: 18px; + gap: 12px; + } + + /* 북마크 왼쪽 상단 고정 */ + .note-card .bookmark-btn { + position: absolute; + top: 14px; + left: 14px; + z-index: 1; + } + + /* 케밥 오른쪽 상단 고정 */ + .note-card .note-morewrap { + position: absolute; + top: 10px; + right: 10px; + z-index: 1; + } + + /* 본문 여백 확보 */ + .note-main { + padding-top: 32px; + } + + /* 제목/태그 세로 배치 */ + .note-topline { + flex-direction: row; + align-items: flex-start; + gap: 6px; + } + + .note-title { + font-size: 17px; + white-space: normal; + line-height: 1.3; + } + + .note-tag { + font-size: 11px; + padding: 5px 10px; + } + + .note-preview { + font-size: 14px; + line-clamp: 3; + -webkit-line-clamp: 3; + } + + .note-date { + font-size: 13px; + } + + /* Empty 상태 */ + .ref-empty-title { + font-size: 24px; + } + + .ref-empty-cta { + padding: 9px 20px; + font-size: 14px; + } +} + +/* ===== 소형 모바일 (480px 이하) ===== */ +@media (max-width: 480px) { + .ref-controls, + .ref-list { + max-width: 100%; + padding: 0 10px; + } + + .ref-searchbar { + padding: 3px 5px 3px 8px; + } + + .ref-searchicon { + width: 22px; + height: 22px; + } + + .ref-searchinput { + font-size: 13px; + } + + .ref-searchbtn { + padding: 7px 12px; + font-size: 11px; + } + + .ref-selectbox { + font-size: 12px; + padding: 8px 32px 8px 12px; + } + + .bookmark-filter-btn { + padding: 8px 12px; + font-size: 11px; + } + + .ref-writebtn { + padding: 8px 12px; + font-size: 11px; + } + + .note-card { + padding: 16px; + border-radius: 18px; + } + + .note-card .bookmark-btn { + top: 12px; + left: 12px; + } + + .note-card .note-morewrap { + top: 8px; + right: 8px; + } + + .note-main { + padding-top: 28px; + } + + .note-title { + font-size: 16px; + } + + .note-tag { + font-size: 10px; + padding: 4px 8px; + } + + .note-preview { + font-size: 13px; + } + + .note-date { + font-size: 12px; + } + + .note-menu { + width: 100px; + } + + .note-menuitem { + padding: 8px 12px; + font-size: 12px; + } + + .ref-empty-title { + font-size: 20px; + } + + .ref-empty-cta { + padding: 8px 16px; + font-size: 13px; + } +} + +/* note_form 반응형 */ +/* ===== 노트북 (1024px ~ 1200px) ===== */ +@media (max-width: 1200px) { + .ref-form-wrap { + max-width: 90%; + padding: 40px 36px 30px; + } + + .ref-title { + font-size: 36px; + } +} + +/* ===== 태블릿 (768px ~ 1023px) ===== */ +@media (max-width: 1023px) { + .ref-form-wrap { + max-width: 95%; + padding: 36px 32px 28px; + } + + .ref-title { + font-size: 32px; + } + + .ref-meta { + grid-template-columns: 1fr; /* 세로 1줄 */ + } + + .ref-textarea { + width: 100%; + border: 0; + outline: 0; + resize: none; /* 수동 리사이즈 제거 */ + overflow: hidden; /* 스크롤 제거 */ + min-height: 120px; /* 기본 높이 */ + font-size: 14.5px; + line-height: 1.55; + /* color: var(--gray-700); */ + } + + .ref-assets-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); /* 4개 → 3개 */ + } +} + +/* ===== 모바일 (480px ~ 767px) ===== */ +@media (max-width: 767px) { + .ref-form-wrap { + max-width: 100%; + padding: 28px 20px 24px; + border-radius: 20px; + margin: 20px auto 80px; + } + + .ref-form-head { + flex-direction: row; + align-items: stretch; + gap: 12px; + } + + .ref-title { + font-size: 24px; + padding: 6px 0; + } + + .ref-head-right { + justify-content: flex-end; + } + + .ref-divider { + margin: 14px 0 20px; + } + + .ref-meta { + grid-template-columns: 1fr; + gap: 14px; + } + + .ref-label { + font-size: 14px; + margin-left: 3px; + } + + .ref-select { + padding: 10px 12px; + font-size: 13px; + } + + .ref-select[disabled]{ + background: #f9fafb; + color: var(--gray-600); + cursor: not-allowed; + opacity: 1; /* disabled 기본 투명도 제거 */ + } + + + .ref-meta-hint { + font-size: 11px; + margin-left: 5px; + } + + .ref-q-list { + gap: 16px; + } + + .ref-q-head { + padding: 10px 14px; + flex-wrap: wrap; + } + + .ref-q-title { + flex: 1; + font-weight: 600; + font-size: 14px; + } + + .ref-q-num { + width: 20px; + height: 20px; + font-size: 11px; + } + + .ref-q-actions { + gap: 8px; + } + + .ref-iconbtn { + padding: 5px; + } + + .ref-iconbtn svg { + width: 18px; + height: 18px; + } + + .ref-q-body { + padding: 12px; + } + + .ref-textarea { + font-size: 13.5px; + min-height: 120px; + } + + .ref-assets-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); /* 3개 → 2개 */ + } + + .ref-asset-img { + height: 120px; + } + + .ref-form-actions { + margin-top: 18px; + gap: 10px; + } + + .ref-btn { + padding: 9px 16px; + font-size: 13px; + } +} + +/* ===== 소형 모바일 (480px 이하) ===== */ +@media (max-width: 480px) { + .ref-form-wrap { + padding: 24px 16px 20px; + border-radius: 16px; + } + + .ref-title { + font-size: 20px; + padding: 4px 0; + } + + .ref-divider { + margin: 12px 0 16px; + } + + .ref-meta { + gap: 12px; + } + + .ref-label { + font-size: 13px; + } + + .ref-select { + padding: 9px 10px; + font-size: 12px; + } + + .ref-form-selectchev { + width: 24px; + height: 24px; + } + + .ref-form-selectchev svg { + width: 16px; + height: 16px; + } + + .ref-meta-hint { + font-size: 10px; + } + + .ref-q-list { + gap: 14px; + } + + .ref-q-head { + padding: 8px 12px; + } + + .ref-q-title { + font-size: 13px; + } + + .ref-q-num { + width: 18px; + height: 18px; + font-size: 10px; + } + + .ref-q-actions { + gap: 6px; + } + + .ref-iconbtn { + padding: 4px; + } + + .ref-iconbtn svg { + width: 16px; + height: 16px; + } + + .ref-q-body { + padding: 10px; + } + + .ref-textarea { + font-size: 13px; + min-height: 100px; + } + + .ref-assets-grid { + grid-template-columns: 1fr; /* 2개 → 1개 */ + gap: 10px; + } + + .ref-asset-img { + height: 100px; + } + + .ref-asset-del { + padding: 8px 10px; + font-size: 12px; + } + + .ref-form-actions { + margin-top: 16px; + gap: 8px; + } + + .ref-btn { + padding: 8px 14px; + font-size: 12px; + } + + /* 케밥 메뉴 */ + .note-menu { + width: 100px; + } + + .note-menuitem { + padding: 8px 10px; + font-size: 12px; + } +} \ No newline at end of file diff --git a/static/css/signup.css b/static/css/signup.css new file mode 100644 index 0000000..65e7ce9 --- /dev/null +++ b/static/css/signup.css @@ -0,0 +1,356 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: #f5f5f5; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 20px; + margin: 0; +} + +.container { + width: 100%; + max-width: 445px; +} + +.signup-card { + background: white; + border-radius: 16px; + padding: 48px 75px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + width: 450px; +} + +.title { + text-align: center; + font-size: 24px; + font-weight: 600; + color: #333; + margin-bottom: 30px; +} + +.signup-form { + display: flex; + flex-direction: column; + /* margin-left: -5px; + margin-right: -5px; */ + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + font-size: 14px; + color: #666; + font-weight: 500; +} + +.input-wrapper { + display: flex; + gap: 8px; +} + +.input { + flex: 1; + padding: 12px 16px; + border: none; + background-color: #f0f0f0; + border-radius: 6px; + font-size: 14px; + outline: none; +} + +.input:focus { + background-color: #e8e8e8; +} + +.check-btn { + padding: 12px 20px; + background-color: #4a7cff; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.submit-btn { + margin-top: 10px; + padding: 14px; + background-color: #4a7cff; + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 600; +} + +.message { + font-size: 12px; +} + +.message.success { + color: #22c55e; +} + +.message.error { + color: #ef4444; +} + +.error-messages .error { + color: #ef4444; + font-size: 14px; +} + +.social-login { + justify-content: center; + gap: 16px; + margin-top: 32px; + padding-top: 32px; + border-top: 1px solid #e0e0e0; + text-align: center; +} + +.social-title-1 { + font-size: 14px; + color: #666; + margin-bottom: 12px; +} + +.social-icons { + display: flex; + justify-content: center; + gap: 16px; +} + +.social-icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: white; + border: 0.5px solid #e0e0e0; + transition: all 0.3s ease; + cursor: pointer; +} + +.social-icon:hover { + border-color: #4285f4; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(66, 133, 244, 0.2); +} + +.social-icon img { + width: 100%; + height: 100%; + border-radius: 50%; +} + +/* 태블릿 (768px 이하) */ +@media (max-width: 768px) { + .container { + width: 400px; + } + + .signup-card { + width: 100%; + padding: 40px 60px; + } + + .title { + font-size: 22px; + margin-bottom: 28px; + } + + .label { + font-size: 13px; + } + + .input { + padding: 11px 14px; + font-size: 13px; + } + + .check-btn { + padding: 11px 18px; + font-size: 13px; + } + + .submit-btn { + padding: 12px; + font-size: 15px; + } + + .message { + font-size: 11px; + } + + .social-login { + margin-top: 28px; + padding-top: 28px; + } + + .social-title-1 { + font-size: 13px; + margin-bottom: 10px; + } + + .social-icons { + gap: 14px; + } + + .social-icon { + width: 42px; + height: 42px; + } +} + +/* 모바일 (480px 이하) */ +@media (max-width: 480px) { + body { + padding: 15px; + } + + .container { + width: 350px; + } + + .signup-card { + width: 100%; + padding: 32px 24px; + } + + .title { + font-size: 20px; + margin-bottom: 24px; + } + + .signup-form { + gap: 14px; + } + + .form-group { + gap: 6px; + } + + .label { + font-size: 13px; + } + + .input { + padding: 11px 14px; + font-size: 13px; + } + + .check-btn { + padding: 11px 16px; + font-size: 13px; + } + + .submit-btn { + padding: 12px; + font-size: 14px; + margin-top: 6px; + } + + .message { + font-size: 11px; + } + + .error-messages .error { + font-size: 13px; + } + + .social-login { + margin-top: 24px; + padding-top: 24px; + } + + .social-title-1 { + font-size: 13px; + margin-bottom: 10px; + } + + .social-icons { + gap: 12px; + } + + .social-icon { + width: 40px; + height: 40px; + } +} + +/* 극소형 모바일 (360px 이하) */ +@media (max-width: 360px) { + .container { + width: 320px; + } + + .signup-card { + width: 100%; + padding: 28px 20px; + } + + .title { + font-size: 18px; + margin-bottom: 20px; + } + + .signup-form { + gap: 12px; + } + + .label { + font-size: 12px; + } + + .input { + padding: 10px 12px; + font-size: 12px; + } + + .check-btn { + padding: 10px 14px; + font-size: 12px; + } + + .submit-btn { + padding: 10px; + font-size: 13px; + } + + .message { + font-size: 10px; + } + + .social-login { + margin-top: 20px; + padding-top: 20px; + } + + .social-title-1 { + font-size: 12px; + } + + .social-icons { + gap: 10px; + } + + .social-icon { + width: 38px; + height: 38px; + } +} \ No newline at end of file diff --git a/static/css/team.css b/static/css/team.css new file mode 100644 index 0000000..29ac1db --- /dev/null +++ b/static/css/team.css @@ -0,0 +1,584 @@ +body { + background: #F6F8FF; +} + +/* 매칭 성공 */ +.team_success { + margin-top: 7%; + text-align: center; + align-items: center; +} + +.team_success h3 { + font-size: 25px; font-weight: 550; +} + +.team_member { + margin-top: 30px; + display: flex; + justify-content: center; gap: 20px; +} + +.t_member { + position: relative; + width: 16%; + height: 250px; + padding: 20px; + background: #fff; + border-radius: 20px; + box-shadow: 0 2px 2px 0 #999; +} + +.profile_section { + position: relative; + width: 100%; height: 110px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 15px; +} + +.t_member > .profile_section > .profile_img { + width: 110px; height: 110px; + border-radius: 50%; + object-fit: cover; + object-position: center; +} + +.t_member >.profile_section> .level_img { + width: 40px; height: 40px; + position: absolute; + bottom: 0; + right: calc(50% - 60px); +} + +.t_member .u_name { + margin: 10px 0; + font-size: 16px; font-weight: 500; +} + +.t_member .u_name > span { + font-size: 14px; + color: #999; +} + +.t_member .u_design { + width: 45%; + padding: 5px 10px; + color: #00B9B0; + border-radius: 20px; + box-shadow: 1px 2px 2px 1px #00B9B0; + font-size: 16px; + position: absolute; + bottom: 25px; + left: 50%; + transform: translateX(-50%); +} + +.t_member .u_frontend { + width: 60%; + padding: 5px 10px; + color: #FFCE53; + border-radius: 20px; + box-shadow: 1px 2px 2px 1px #FFCE53; + font-size: 16px; + position: absolute; + bottom: 25px; + left: 50%; + transform: translateX(-50%); +} + +.t_member .u_backend { + width: 45%; + padding: 5px 10px; + color: #FF3E88; + border-radius: 20px; + box-shadow: 1px 2px 2px 1px #FF3E88; + font-size: 16px; + position: absolute; + bottom: 25px; + left: 50%; + transform: translateX(-50%); +} + +.go_project { + display: block; + width: 20%; height: 40px; + margin: 50px auto 10%; + padding: 10px 10px; + background: #1F4CC0; + text-decoration: none; color: #fff; + border-radius: 20px; +} + +.go_project p { + font-size: 15px; font-weight: 500; +} + +.go_project:hover { + background: #4272EF; + transition: 0.3s ease; +} + +/* 매칭 실패 */ +.team_fail { + margin-top: 15%; + text-align: center; + align-items: center; +} + +.team_fail h3 { + font-size: 30px; font-weight: 550; +} + +.team_fail a { + display: block; + width: 20%; height: 40px; + margin: 50px auto 0; + padding: 10px 10px; + background: #1F4CC0; + text-decoration: none; color: #fff; + border-radius: 20px; + margin-bottom: 10%; +} + +.team_fail a p { + font-size: 18px; font-weight: 500; +} + +.team_fail a:hover { + background: #4272EF; + transition: 0.3s ease; +} + + +/* =================================== + 반응형 미디어 쿼리 + =================================== */ + +/* 노트북 (1024px ~ 1400px) - 5명 한 줄 유지 */ +@media (max-width: 1400px) { + .team_success h3 { + font-size: 23px; + } + + .team_member { + gap: 15px; + } + + .t_member { + width: 17%; + height: 240px; + padding: 18px; + } + + .profile_section { + height: 100px; + } + + .t_member > .profile_section > .profile_img { + width: 100px; + height: 100px; + } + + .t_member > .profile_section > .level_img { + width: 35px; + height: 35px; + right: calc(50% - 55px); + } + + .t_member .u_name { + font-size: 15px; + } + + .t_member .u_name > span { + font-size: 13px; + } + + .t_member .u_design, + .t_member .u_frontend, + .t_member .u_backend { + font-size: 15px; + bottom: 20px; + } + + .go_project { + width: 25%; + } + + .team_fail h3 { + font-size: 28px; + } + + .team_fail a { + width: 25%; + } + + .team_fail a p { + font-size: 17px; + } +} + +@media (max-width: 1024px) { + .team_success { + margin-top: 10%; + } + + .team_success h3 { + font-size: 21px; + } + + .team_member { + gap: 12px; + } + + .t_member { + width: 18%; + height: 220px; + padding: 15px; + } + + .profile_section { + height: 90px; + } + + .t_member > .profile_section > .profile_img { + width: 90px; + height: 90px; + } + + .t_member > .profile_section > .level_img { + width: 32px; + height: 32px; + right: calc(50% - 50px); + } + + .t_member .u_name { + font-size: 14px; + margin: 8px 0; + } + + .t_member .u_name > span { + font-size: 12px; + } + + .t_member .u_design, + .t_member .u_frontend, + .t_member .u_backend { + font-size: 13px; + padding: 4px 8px; + bottom: 18px; + } + + .go_project { + width: 30%; + height: 38px; + } + + .go_project p { + font-size: 14px; + } + + .team_fail h3 { + font-size: 26px; + } + + .team_fail a { + width: 30%; + height: 38px; + } + + .team_fail a p { + font-size: 16px; + } +} + +/* 태블릿 (~ 768px) - 위 3개, 아래 2개 */ +@media (max-width: 768px) { + .team_success { + margin-top: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 20px 0; + } + + .team_success h3 { + font-size: 18px; + padding: 0 20px; + line-height: 1.4; + } + + .team_member { + width: 90%; + max-width: 550px; + margin: 25px auto 0; + gap: 12px; + justify-content: center; + flex-wrap: wrap; + } + + .t_member { + width: 30%; + min-width: 150px; + max-width: 170px; + max-height: 190px; + padding: 12px; + } + + .profile_section { + height: 85px; + margin-bottom: 10px; + } + + .t_member > .profile_section > .profile_img { + width: 85px; + height: 85px; + } + + .t_member > .profile_section > .level_img { + width: 30px; + height: 30px; + right: calc(50% - 47px); + } + + .t_member .u_name { + font-size: 13px; + margin: 8px 0; + } + + .t_member .u_name > span { + font-size: 11px; + } + + .t_member .u_design, + .t_member .u_frontend, + .t_member .u_backend { + font-size: 12px; + padding: 3px 6px; + bottom: 15px; + } + + .t_member .u_design { + width: 50%; + } + + .t_member .u_frontend { + width: 65%; + } + + .t_member .u_backend { + width: 50%; + } + + .go_project { + width: 45%; + height: 38px; + margin-top: 30px; + margin-bottom: 0; + } + + .go_project p { + font-size: 13px; + } + + .team_fail { + margin-top: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .team_fail h3 { + font-size: 22px; + padding: 0 20px; + line-height: 1.4; + } + + .team_fail a { + width: 45%; + height: 38px; + margin-bottom: 0; + } + + .team_fail a p { + font-size: 15px; + } +} + +/* 모바일 (~ 480px) - 위 3개, 아래 2개 유지 */ +@media (max-width: 480px) { + .team_success { + margin-top: -7%; + } + + .team_success h3 { + font-size: 16px; + padding: 0 15px; + } + + .team_member { + width: 95%; + max-width: 100%; + gap: 10px; + margin-top: 20px; + } + + .t_member { + width: 30%; + min-width: 95px; + max-width: 110px; + max-height: 150px; + padding: 10px; + } + + .profile_section { + height: 65px; + margin-bottom: 8px; + } + + .t_member > .profile_section > .profile_img { + width: 65px; + height: 65px; + } + + .t_member > .profile_section > .level_img { + width: 24px; + height: 24px; + right: calc(50% - 38px); + } + + .t_member .u_name { + font-size: 11px; + margin: 6px 0; + } + + .t_member .u_name > span { + font-size: 10px; + } + + .t_member .u_design, + .t_member .u_frontend, + .t_member .u_backend { + font-size: 10px; + padding: 3px 5px; + bottom: 12px; + } + + .t_member .u_design { + width: 50%; + } + + .t_member .u_frontend { + width: 70%; + } + + .t_member .u_backend { + width: 50%; + } + + .go_project { + width: 55%; + height: 36px; + margin-top: 25px; + } + + .go_project p { + font-size: 12px; + } + + .team_fail h3 { + font-size: 18px; + padding: 0 15px; + } + + .team_fail a { + width: 55%; + height: 36px; + } + + .team_fail a p { + font-size: 13px; + } +} + +/* 극소형 모바일 (~ 360px) */ +@media (max-width: 360px) { + .team_success h3 { + font-size: 14px; + padding: 0 10px; + } + + .team_member { + gap: 8px; + margin-top: 18px; + } + + .t_member { + width: 30%; + min-width: 85px; + max-width: 95px; + max-height: 125px; + padding: 8px; + } + + .profile_section { + height: 58px; + margin-bottom: 6px; + } + + .t_member > .profile_section > .profile_img { + width: 58px; + height: 58px; + } + + .t_member > .profile_section > .level_img { + width: 22px; + height: 22px; + right: calc(50% - 34px); + } + + .t_member .u_name { + font-size: 10px; + margin: 5px 0; + } + + .t_member .u_name > span { + font-size: 9px; + } + + .t_member .u_design, + .t_member .u_frontend, + .t_member .u_backend { + font-size: 9px; + padding: 2px 4px; + bottom: 10px; + } + + .go_project { + width: 65%; + height: 34px; + margin-top: 20px; + } + + .go_project p { + font-size: 11px; + } + + .team_fail h3 { + font-size: 16px; + } + + .team_fail a { + width: 65%; + height: 34px; + } + + .team_fail a p { + font-size: 12px; + } +} \ No newline at end of file diff --git a/static/css/team_apply.css b/static/css/team_apply.css new file mode 100644 index 0000000..3894c0b --- /dev/null +++ b/static/css/team_apply.css @@ -0,0 +1,538 @@ +body { + background: #f6f8ff; +} + +button:hover { + cursor: pointer; +} + +/* 팀 매칭 기간 X */ +.match_wait { + margin-top: 15%; + text-align: center; + align-items: center; +} + +.match_wait h3 { + font-size: 27px; + font-weight: 600; + margin-bottom: 20px; +} + +.match_wait .noti { + margin: 30px auto 0; +} + +/* 매칭 대기 */ +.team_waiting { + margin-top: 15%; + text-align: center; + align-items: center; +} + +.team_waiting > h3 { + font-size: 27px; + font-weight: 600; + margin-bottom: 20px; +} + +/* 매칭 취소 */ +.matching_actions { + margin-top: 30px; + display: flex; + justify-content: center; + gap: 50px; +} + +.noti_button { + width: 150px; + height: 40px; + padding: 10px 20px; + background: #4272EF; + color: #fff; + border: none; + border-radius: 20px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: 0.3s ease; +} + +.noti_button:hover { + background: #1A3C97; + transition: 0.3s ease; +} + +.cancel_form { + display: flex; + justify-content: center; +} + +.cancel_button { + width: 150px; + height: 40px; + padding: 10px 20px; + background: #FF6B6B; + color: #fff; + border: none; + border-radius: 20px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: 0.3s ease; +} + +.cancel_button:hover { + background: #E63946; + transition: 0.3s ease; +} + + +/* 팀 매칭 기간 O / 매칭 신청 X */ +.team_matching { + margin-top: 10%; + text-align: center; +} + +.team_matching h3 { + font-size: 30px; + margin-bottom: 20px; +} + +.team_matching p { + font-size: 15px; +} + +.t_stack { + width: 90%; + display: flex; + margin: 40px auto 0; + justify-content: center; + gap: 40px; +} + +/* WEB 기획 */ +.t_stack .t_design { + padding: 20px 0; + width: 27%; + height: 3%; + text-align: center; + background: #fff; + border: 3px solid #00b9b050; + border-radius: 40px; +} + +.t_stack .t_design h3 { + font-weight: 600; + font-size: 20px; +} + +.t_stack .t_design img { + width: 40px; + height: 40px; +} + +.t_stack .t_nolevel { + margin-top: 5px; + color: #ff0202; + font-size: 14px; +} + +.t_stack .t_design .t_design_btn { + margin-top: 15px; + padding: 5px 0; + width: 70%; + height: 33px; + background: #00b9b0; + color: #fff; + border: none; + border-radius: 30px; + font-size: 15px; + font-weight: 550; +} + +.t_stack .t_design .t_design_btn:hover { + background: #019890; + transition: 0.5s ease-in-out; +} + +/* WEB 프론트엔드 */ +.t_stack .t_frontend { + padding: 20px 0; + width: 27%; + height: 3%; + text-align: center; + background: #fff; + border: 3px solid #ffce5350; + border-radius: 40px; +} + +.t_stack .t_frontend h3 { + font-weight: 600; + font-size: 20px; +} + +.t_stack .t_frontend img { + width: 40px; + height: 40px; +} + +.t_stack .t_frontend .t_level { + margin-top: 5px; + color: #000; + font-size: 14px; +} + +.t_stack .t_frontend .t_front_btn { + margin-top: 15px; + padding: 5px 0; + width: 70%; + height: 33px; + background: #ffce53; + color: #fff; + border: none; + border-radius: 30px; + font-size: 15px; + font-weight: 550; +} + +.t_stack .t_frontend .t_front_btn:hover { + background: #E2BF67; + transition: 0.5s ease-in-out; +} + +/* WEB 백엔드 */ +.t_stack .t_backend { + padding: 20px 0; + width: 27%; + height: 3%; + text-align: center; + background: #fff; + border: 3px solid #ff3e8850; + border-radius: 40px; +} + +.t_stack .t_backend h3 { + font-weight: 600; + font-size: 20px; +} + +.t_stack .t_backend img { + width: 40px; + height: 40px; +} + +.t_stack .t_backend .t_level { + margin-top: 5px; + color: #000; + font-size: 14px; +} + +.t_stack .t_backend .t_back_btn { + margin-top: 15px; + padding: 5px 0; + width: 70%; + height: 33px; + background: #ff3e88; + color: #fff; + border: none; + border-radius: 30px; + font-size: 15px; + font-weight: 550; +} + +.t_stack .t_backend .t_back_btn:hover { + background: #C03067; + transition: 0.5s ease-in-out; +} + +/* ======================================== + 반응형 미디어 쿼리 +======================================== */ + +/* 데스크탑 (1200px 이상) - 기본 스타일 유지 */ +@media (min-width: 1200px) { + .t_stack { + width: 90%; + } + + .t_stack .t_design, + .t_stack .t_frontend, + .t_stack .t_backend { + width: 27%; + } +} + +/* 노트북 (768px ~ 1199px) */ +@media (max-width: 1199px) { + .match_wait { + margin-top: 18%; + } + + .match_wait h3 { + font-size: 25px; + } + + .team_waiting { + margin-top: 18%; + } + + .team_waiting > h3 { + font-size: 25px; + } + + .matching_actions { + gap: 40px; + } + + .noti_button, + .cancel_button { + width: 140px; + font-size: 14px; + } + + .team_matching { + margin-top: 12%; + } + + .team_matching h3 { + font-size: 27px; + } + + .t_stack { + width: 92%; + gap: 30px; + } + + .t_stack .t_design, + .t_stack .t_frontend, + .t_stack .t_backend { + width: 30%; + padding: 18px 0; + } + + .t_stack .t_design h3, + .t_stack .t_frontend h3, + .t_stack .t_backend h3 { + font-size: 19px; + } + + .t_stack .t_design img, + .t_stack .t_frontend img, + .t_stack .t_backend img { + width: 38px; + height: 38px; + } + + .t_stack .t_nolevel, + .t_stack .t_frontend .t_level, + .t_stack .t_backend .t_level { + font-size: 13px; + } + + .t_stack .t_design .t_design_btn, + .t_stack .t_frontend .t_front_btn, + .t_stack .t_backend .t_back_btn { + width: 75%; + font-size: 14px; + height: 32px; + } +} + +/* 태블릿 (481px ~ 767px) */ +@media (max-width: 767px) { + .match_wait { + margin-top: 25%; + } + + .match_wait h3 { + font-size: 22px; + padding: 0 20px; + line-height: 1.4; + } + + .team_waiting { + margin-top: 25%; + } + + .team_waiting > h3 { + font-size: 22px; + padding: 0 20px; + line-height: 1.4; + } + + .matching_actions { + flex-direction: column; + align-items: center; + gap: 20px; + } + + .noti_button, + .cancel_button { + width: 200px; + height: 42px; + font-size: 15px; + } + + .team_matching { + margin-top: 15%; + } + + .team_matching h3 { + font-size: 24px; + padding: 0 20px; + } + + .team_matching p { + padding: 0 20px; + font-size: 14px; + } + + .t_stack { + flex-direction: column; + align-items: center; + width: 85%; + gap: 25px; + margin-top: 35px; + } + + .t_stack .t_design, + .t_stack .t_frontend, + .t_stack .t_backend { + width: 75%; + padding: 25px 20px; + } + + .t_stack .t_design h3, + .t_stack .t_frontend h3, + .t_stack .t_backend h3 { + font-size: 20px; + margin-bottom: 10px; + } + + .t_stack .t_design img, + .t_stack .t_frontend img, + .t_stack .t_backend img { + width: 45px; + height: 45px; + } + + .t_stack .t_nolevel, + .t_stack .t_frontend .t_level, + .t_stack .t_backend .t_level { + font-size: 15px; + margin-top: 8px; + } + + .t_stack .t_design .t_design_btn, + .t_stack .t_frontend .t_front_btn, + .t_stack .t_backend .t_back_btn { + width: 75%; + font-size: 16px; + height: 38px; + margin-top: 18px; + } +} + +/* 모바일 (480px 이하) */ +@media (max-width: 480px) { + .match_wait { + margin-top: 35%; + } + + .match_wait h3 { + font-size: 19px; + padding: 0 15px; + line-height: 1.5; + } + + .match_wait .noti { + margin-top: 25px; + } + + .team_waiting { + margin-top: 35%; + } + + .team_waiting > h3 { + font-size: 19px; + padding: 0 15px; + line-height: 1.5; + } + + .matching_actions { + flex-direction: column; + align-items: center; + gap: 15px; + margin-top: 25px; + } + + .noti_button, + .cancel_button { + width: 180px; + height: 40px; + font-size: 14px; + } + + .team_matching { + margin-top: 20%; + } + + .team_matching h3 { + font-size: 20px; + padding: 0 15px; + line-height: 1.4; + } + + .team_matching p { + padding: 0 15px; + font-size: 13px; + line-height: 1.5; + } + + .t_stack { + flex-direction: column; + align-items: center; + width: 90%; + gap: 20px; + margin-top: 30px; + } + + .t_stack .t_design, + .t_stack .t_frontend, + .t_stack .t_backend { + width: 85%; + padding: 22px 15px; + border-radius: 30px; + } + + .t_stack .t_design h3, + .t_stack .t_frontend h3, + .t_stack .t_backend h3 { + font-size: 18px; + margin-bottom: 8px; + } + + .t_stack .t_design img, + .t_stack .t_frontend img, + .t_stack .t_backend img { + width: 40px; + height: 40px; + } + + .t_stack .t_nolevel, + .t_stack .t_frontend .t_level, + .t_stack .t_backend .t_level { + font-size: 13px; + margin-top: 6px; + } + + .t_stack .t_design .t_design_btn, + .t_stack .t_frontend .t_front_btn, + .t_stack .t_backend .t_back_btn { + width: 85%; + font-size: 14px; + height: 36px; + margin-top: 15px; + } +} \ No newline at end of file diff --git a/static/css/test_result.css b/static/css/test_result.css new file mode 100644 index 0000000..42add7d --- /dev/null +++ b/static/css/test_result.css @@ -0,0 +1,207 @@ +/* test_result.css */ + +.test-result-container { + min-height: calc(100vh - 100px); + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif; + background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%); +} + +.result-wrapper { + max-width: 600px; + width: 100%; + text-align: center; +} + +/* 타이틀 */ +.result-title { + font-size: 32px; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 40px; + letter-spacing: -0.5px; +} + +/* 레벨 카드 */ +.level-card { + background: #ffffff; + border-radius: 24px; + padding: 60px 40px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08); + margin-bottom: 32px; + position: relative; +} + +/* 레벨 아이콘 */ +.level-icon-wrapper { + margin-bottom: 24px; +} + +.level-icon { + width: 120px; + height: 120px; + margin: 0 auto; + border-radius: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 64px; + position: relative; +} + +.level-icon.level-1 { + background: linear-gradient(135deg, #d4f1d4, #b8e6b8); + box-shadow: 0 8px 24px rgba(184, 230, 184, 0.4); +} + +.level-icon.level-2 { + background: linear-gradient(135deg, #b8e6d5, #8ed1b8); + box-shadow: 0 8px 24px rgba(142, 209, 184, 0.4); +} + +.level-icon.level-3 { + background: linear-gradient(135deg, #8ed1ff, #5b9cff); + box-shadow: 0 8px 24px rgba(91, 156, 255, 0.4); +} + +.level-icon.level-4 { + background: linear-gradient(135deg, #ffd700, #ffb700); + box-shadow: 0 8px 24px rgba(255, 215, 0, 0.5); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +/* 레벨 배지 */ +.level-badge { + display: inline-block; + background: linear-gradient(135deg, #5b73e8, #7c8fe8); + color: #ffffff; + font-size: 28px; + font-weight: 700; + padding: 12px 40px; + border-radius: 50px; + margin-bottom: 32px; + box-shadow: 0 4px 16px rgba(91, 115, 232, 0.3); +} + +/* 메시지 */ +.result-message { + margin-top: 24px; +} + +.user-name { + font-size: 22px; + font-weight: 600; + color: #1a1a1a; + margin-bottom: 12px; + line-height: 1.5; +} + +.description { + font-size: 16px; + color: #666; + line-height: 1.6; +} + +/* 홈으로 버튼 */ +.btn-home { + background: #5b73e8; + color: #ffffff; + border: none; + border-radius: 12px; + padding: 16px 48px; + font-size: 18px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(91, 115, 232, 0.3); +} + +.btn-home:hover { + background: #4a5fc8; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(91, 115, 232, 0.4); +} + +.btn-home:active { + transform: translateY(0); +} + +/* 반응형 */ +@media (max-width: 768px) { + .test-result-container { + padding: 24px 16px; + } + + .result-title { + font-size: 24px; + margin-bottom: 32px; + } + + .level-card { + padding: 40px 24px; + border-radius: 20px; + } + + .level-icon { + width: 100px; + height: 100px; + font-size: 52px; + } + + .level-badge { + font-size: 24px; + padding: 10px 32px; + } + + .user-name { + font-size: 18px; + } + + .description { + font-size: 14px; + } + + .btn-home { + width: 100%; + padding: 14px 32px; + font-size: 16px; + } +} + +@media (max-width: 480px) { + .result-title { + font-size: 20px; + } + + .level-card { + padding: 32px 20px; + } + + .level-icon { + width: 80px; + height: 80px; + font-size: 40px; + border-radius: 20px; + } + + .level-badge { + font-size: 20px; + padding: 8px 24px; + } + + .user-name { + font-size: 16px; + } +} \ No newline at end of file diff --git a/static/images/Level1.png b/static/images/Level1.png new file mode 100644 index 0000000..c7e3303 Binary files /dev/null and b/static/images/Level1.png differ diff --git a/static/images/Level2.png b/static/images/Level2.png new file mode 100644 index 0000000..9f7d11f Binary files /dev/null and b/static/images/Level2.png differ diff --git a/static/images/Level3.png b/static/images/Level3.png new file mode 100644 index 0000000..65c681c Binary files /dev/null and b/static/images/Level3.png differ diff --git a/static/images/Level4.png b/static/images/Level4.png new file mode 100644 index 0000000..0e38e1c Binary files /dev/null and b/static/images/Level4.png differ diff --git a/static/images/bookmark.png b/static/images/bookmark.png new file mode 100644 index 0000000..a5d90cc Binary files /dev/null and b/static/images/bookmark.png differ diff --git a/static/images/calender.png b/static/images/calender.png new file mode 100644 index 0000000..e7a9974 Binary files /dev/null and b/static/images/calender.png differ diff --git a/static/images/check.png b/static/images/check.png new file mode 100644 index 0000000..450598d Binary files /dev/null and b/static/images/check.png differ diff --git a/static/images/default_img.png b/static/images/default_img.png new file mode 100644 index 0000000..620fe60 Binary files /dev/null and b/static/images/default_img.png differ diff --git a/static/images/default_profile.png b/static/images/default_profile.png new file mode 100644 index 0000000..9a5c3a5 Binary files /dev/null and b/static/images/default_profile.png differ diff --git a/static/images/example_img.png b/static/images/example_img.png new file mode 100644 index 0000000..3b3fd99 Binary files /dev/null and b/static/images/example_img.png differ diff --git a/static/images/github-icon.png b/static/images/github-icon.png new file mode 100644 index 0000000..d96b8a2 Binary files /dev/null and b/static/images/github-icon.png differ diff --git a/static/images/google-icon.png b/static/images/google-icon.png new file mode 100644 index 0000000..7a438cc Binary files /dev/null and b/static/images/google-icon.png differ diff --git a/static/images/header_img.png b/static/images/header_img.png new file mode 100644 index 0000000..3aee88b Binary files /dev/null and b/static/images/header_img.png differ diff --git a/static/images/kakao-icon.png b/static/images/kakao-icon.png new file mode 100644 index 0000000..c22370c Binary files /dev/null and b/static/images/kakao-icon.png differ diff --git a/static/images/link.png b/static/images/link.png new file mode 100644 index 0000000..608ed1d Binary files /dev/null and b/static/images/link.png differ diff --git a/static/images/naver-icon.jpeg b/static/images/naver-icon.jpeg new file mode 100644 index 0000000..d1a144b Binary files /dev/null and b/static/images/naver-icon.jpeg differ diff --git a/static/images/nocheck.png b/static/images/nocheck.png new file mode 100644 index 0000000..e2526ba Binary files /dev/null and b/static/images/nocheck.png differ diff --git a/static/images/nolevel.png b/static/images/nolevel.png new file mode 100644 index 0000000..7af48b5 Binary files /dev/null and b/static/images/nolevel.png differ diff --git a/static/images/note_img.png b/static/images/note_img.png new file mode 100644 index 0000000..6f4f802 Binary files /dev/null and b/static/images/note_img.png differ diff --git a/static/images/now_check.png b/static/images/now_check.png new file mode 100644 index 0000000..6ab8409 Binary files /dev/null and b/static/images/now_check.png differ diff --git a/static/images/now_nocheck.png b/static/images/now_nocheck.png new file mode 100644 index 0000000..88516a2 Binary files /dev/null and b/static/images/now_nocheck.png differ diff --git a/static/images/pencil_icon.png b/static/images/pencil_icon.png new file mode 100644 index 0000000..e8c7ff7 Binary files /dev/null and b/static/images/pencil_icon.png differ diff --git a/static/images/progress.png b/static/images/progress.png new file mode 100644 index 0000000..fb45d75 Binary files /dev/null and b/static/images/progress.png differ diff --git a/static/images/project_img.png b/static/images/project_img.png new file mode 100644 index 0000000..efbafc1 Binary files /dev/null and b/static/images/project_img.png differ diff --git a/static/images/report.png b/static/images/report.png new file mode 100644 index 0000000..9bc78df Binary files /dev/null and b/static/images/report.png differ diff --git a/static/images/rocket.png b/static/images/rocket.png new file mode 100644 index 0000000..d886252 Binary files /dev/null and b/static/images/rocket.png differ diff --git a/static/images/rule.png b/static/images/rule.png new file mode 100644 index 0000000..6259038 Binary files /dev/null and b/static/images/rule.png differ diff --git a/static/images/search.png b/static/images/search.png new file mode 100644 index 0000000..c6b62b6 Binary files /dev/null and b/static/images/search.png differ diff --git a/static/images/service_default.png b/static/images/service_default.png new file mode 100644 index 0000000..e8e7b88 Binary files /dev/null and b/static/images/service_default.png differ diff --git a/static/images/team_flag.png b/static/images/team_flag.png new file mode 100644 index 0000000..680d342 Binary files /dev/null and b/static/images/team_flag.png differ diff --git a/static/js/base.js b/static/js/base.js new file mode 100644 index 0000000..1cbbb90 --- /dev/null +++ b/static/js/base.js @@ -0,0 +1,107 @@ +document.addEventListener('DOMContentLoaded', function() { + const hamburger = document.querySelector('.hamburger'); + const headerMenu = document.querySelector('.header_menu'); + const mobileOverlay = document.querySelector('.mobile-overlay'); + const dropdowns = document.querySelectorAll('.dropdown'); + + // 메뉴 닫기 함수 + function closeMenu() { + if (hamburger) hamburger.classList.remove('active'); + if (headerMenu) headerMenu.classList.remove('active'); + if (mobileOverlay) mobileOverlay.classList.remove('active'); + document.body.style.overflow = ''; + + // 모든 드롭다운 닫기 + dropdowns.forEach(dropdown => { + dropdown.classList.remove('active'); + }); + } + + // 햄버거 메뉴 토글 + if (hamburger) { + hamburger.addEventListener('click', function(e) { + e.stopPropagation(); + const isActive = this.classList.contains('active'); + + if (isActive) { + closeMenu(); + } else { + this.classList.add('active'); + headerMenu.classList.add('active'); + mobileOverlay.classList.add('active'); + document.body.style.overflow = 'hidden'; + } + }); + } + + // 오버레이 클릭시 메뉴 닫기 + if (mobileOverlay) { + mobileOverlay.addEventListener('click', function(e) { + e.stopPropagation(); + closeMenu(); + }); + } + + // 메뉴 내부 클릭은 전파 막기 + if (headerMenu) { + headerMenu.addEventListener('click', function(e) { + e.stopPropagation(); + }); + } + + // 모바일에서 드롭다운 클릭 처리 + dropdowns.forEach(dropdown => { + const dropdownLink = dropdown.querySelector('a'); + + if (dropdownLink) { + dropdownLink.addEventListener('click', function(e) { + // 768px 이하에서만 드롭다운 토글 + if (window.innerWidth <= 768) { + e.preventDefault(); + e.stopPropagation(); + + const isCurrentlyActive = dropdown.classList.contains('active'); + + // 모든 드롭다운 닫기 + dropdowns.forEach(d => { + d.classList.remove('active'); + }); + + // 현재 드롭다운이 닫혀있었으면 열기 + if (!isCurrentlyActive) { + dropdown.classList.add('active'); + } + } + }); + } + }); + + // 드롭다운 내부 링크 클릭시 메뉴 닫기 + const dropdownLinks = document.querySelectorAll('.dropdown-content a'); + dropdownLinks.forEach(link => { + link.addEventListener('click', function(e) { + if (window.innerWidth <= 768) { + // 링크 클릭시 메뉴 닫기 + setTimeout(() => { + closeMenu(); + }, 100); + } + }); + }); + + // 메뉴 외부 클릭시 닫기 + document.addEventListener('click', function(e) { + if (window.innerWidth <= 768) { + if (!headerMenu.contains(e.target) && !hamburger.contains(e.target)) { + closeMenu(); + } + } + }); + + // 창 크기 변경시 메뉴 상태 초기화 + window.addEventListener('resize', function() { + if (window.innerWidth > 768) { + closeMenu(); + } + }); +}); \ No newline at end of file diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..6ccba75 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,91 @@ +document.addEventListener('DOMContentLoaded', function() { + const modal = document.getElementById('reportModal'); + const openBtn = document.querySelector('.btn_report'); + const closeBtns = document.querySelectorAll('.close-btn'); + const form = document.getElementById("reportForm"); + const textarea = form.querySelector("textarea[name='reason']"); + + + /* CSRF */ + function getCookie(name) { + const v = document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)"); + return v ? v.pop() : ""; + } + + // 팝업 열기 + if(openBtn) { + openBtn.addEventListener('click', function(e) { + e.preventDefault(); + modal.style.display = 'flex'; + }); + } + + // 팝업 닫기 + closeBtns.forEach(btn => { + btn.addEventListener('click', () => { + modal.style.display = 'none'; + form.reset(); + }); + }); + + // 배경 클릭 시 닫기 + window.addEventListener('click', (e) => { + if (e.target === modal) { + modal.style.display = 'none'; + } + }); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const selected = form.querySelector( + "input[name='reported_user']:checked" + ); + const reason = textarea.value.trim(); + + if (!selected) { + alert("신고할 팀원을 선택해주세요."); + return; + } + if (!reason) { + alert("신고 사유를 입력해주세요."); + return; + } + + try { + const res = await fetch("/api/accounts/report/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCookie("csrftoken"), + }, + credentials: "same-origin", + body: JSON.stringify({ + reported_user_id: selected.value, + reason: reason, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + alert(data.error || "신고에 실패했습니다."); + return; + } + + alert("신고가 접수되었습니다."); + modal.style.display = "none"; + form.reset(); + } catch (err) { + alert("네트워크 오류가 발생했습니다."); + } + }); +}); + +document.addEventListener("DOMContentLoaded", function() { + const links = document.querySelectorAll('.l_content a'); + links.forEach(link => { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); // 보안 강화 + }); +}); \ No newline at end of file diff --git a/static/js/dashboard_update.js b/static/js/dashboard_update.js new file mode 100644 index 0000000..d519691 --- /dev/null +++ b/static/js/dashboard_update.js @@ -0,0 +1,10 @@ +document.getElementById("project_image").addEventListener("change", function () { + const file = this.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + document.getElementById("preview_image").src = e.target.result; + }; + reader.readAsDataURL(file); + } +}); \ No newline at end of file diff --git a/static/js/kitup.js b/static/js/kitup.js new file mode 100644 index 0000000..3a5e040 --- /dev/null +++ b/static/js/kitup.js @@ -0,0 +1,66 @@ +// static/js/kitup.js + +// CSRF 토큰 가져오기 +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +// 하트 버튼 클릭 핸들러 +document.addEventListener("click", async (e) => { + const btn = e.target.closest(".kitup-like-btn"); + if (!btn) return; + + // 이벤트 전파 방지 + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + const projectId = btn.dataset.projectId; + + console.log('좋아요 클릭:', projectId); + + try { + // 좋아요 API 호출 + const response = await fetch(`/projects/all/${projectId}/like/`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + }, + }); + + console.log('API 응답:', response.status); + + if (!response.ok) { + throw new Error(`API 오류: ${response.status}`); + } + + const data = await response.json(); + console.log('응답 데이터:', data); + + // UI 업데이트 + btn.dataset.liked = String(data.is_liked); + btn.setAttribute("aria-pressed", String(data.is_liked)); + + // 좋아요 수 업데이트 + let countEl = btn.nextElementSibling; + if (countEl && countEl.classList.contains('kitup-like-count')) { + countEl.textContent = data.like_count; + console.log('좋아요 수 업데이트:', data.like_count); + } + } catch (error) { + console.error('좋아요 처리 중 오류:', error); + alert('좋아요 처리 중 오류가 발생했습니다: ' + error.message); + } +}, true); // 캡처 단계에서 처리 + diff --git a/static/js/mission.js b/static/js/mission.js new file mode 100644 index 0000000..e0a564e --- /dev/null +++ b/static/js/mission.js @@ -0,0 +1,81 @@ +document.addEventListener('DOMContentLoaded', function() { + if (!PROJECT_ID) return; + + // 체크 아이콘 클릭 + document.querySelectorAll('.check_icon').forEach(icon => { + icon.addEventListener('click', function(e) { + e.stopPropagation(); + + const missionItem = this.closest('.mission_item'); + const cardId = missionItem.dataset.cardId; + const isCompleted = missionItem.classList.contains('completed'); + + // UI 업데이트 + if (isCompleted) { + missionItem.classList.remove('completed'); + this.src = this.src.replace('check.png', 'nocheck.png'); + } else { + missionItem.classList.add('completed'); + this.src = this.src.replace('nocheck.png', 'check.png'); + } + + // DB 저장 (API URL 사용) + fetch(`/api/guides/card/${cardId}/toggle/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + project_id: PROJECT_ID, + is_completed: !isCompleted + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + location.reload(); + } + }) + .catch(error => console.error('Error:', error)); + }); + }); + + // 카드 펼치기/접기 + document.querySelectorAll('.card_header').forEach(header => { + header.addEventListener('click', function(e) { + if (e.target.classList.contains('check_icon')) return; + + const card = this.closest('.mission_card'); + const missionItem = this.closest('.mission_item'); + + // 다른 카드 닫기 + document.querySelectorAll('.mission_card').forEach(c => { + c.classList.remove('active'); + }); + document.querySelectorAll('.mission_item').forEach(item => { + item.classList.remove('active'); + }); + + // 현재 카드 토글 + card.classList.toggle('active'); + missionItem.classList.toggle('active'); + }); + }); + + // CSRF 토큰 가져오기 + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } +}); \ No newline at end of file diff --git a/static/js/reflections.js b/static/js/reflections.js new file mode 100644 index 0000000..5394a44 --- /dev/null +++ b/static/js/reflections.js @@ -0,0 +1,502 @@ +// static/js/reflections.js +(() => { + /** --------------------------- + * Helpers + * -------------------------- */ + const qs = (sel, el = document) => el.querySelector(sel); + const qsa = (sel, el = document) => Array.from(el.querySelectorAll(sel)); + + const closeAllMenus = (exceptWrap = null) => { + qsa("[data-more-wrap]").forEach((wrap) => { + if (exceptWrap && wrap === exceptWrap) return; + const btn = qs("[data-more-btn]", wrap); + const menu = qs("[data-more-menu]", wrap); + if (!btn || !menu) return; + btn.setAttribute("aria-expanded", "false"); + menu.hidden = true; + }); + }; + + const showToast = (message, type = "success") => { + const toast = document.createElement("div"); + toast.className = `ref-toast ref-toast-${type}`; + toast.textContent = message; + + document.body.appendChild(toast); + + requestAnimationFrame(() => { + toast.classList.add("show"); + }); + + setTimeout(() => { + toast.classList.remove("show"); + setTimeout(() => toast.remove(), 300); + }, 2000); + }; + + /** --------------------------- + * 1) Kebab menu (수정/삭제) + * - 버튼 클릭: 해당 메뉴 토글 + * - 바깥 클릭: 모두 닫기 + * - ESC: 닫기 + * -------------------------- */ + const bindMenus = () => { + qsa("[data-more-wrap]").forEach((wrap) => { + const btn = qs("[data-more-btn]", wrap); + const menu = qs("[data-more-menu]", wrap); + if (!btn || !menu) return; + + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + const isOpen = btn.getAttribute("aria-expanded") === "true"; + closeAllMenus(wrap); + + // 토글 + btn.setAttribute("aria-expanded", String(!isOpen)); + menu.hidden = isOpen; + + // 열릴 때만 포커스 이동(접근성) + if (!isOpen) { + const firstItem = qs(".note-menuitem", menu); + if (firstItem) firstItem.focus?.(); + } + }); + + // 메뉴 내부 클릭은 바깥 클릭 닫기 막기 + menu.addEventListener("click", (e) => e.stopPropagation()); + }); + + // 바깥 클릭하면 닫기 + document.addEventListener("click", () => closeAllMenus(null)); + + // ESC 누르면 닫기 + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeAllMenus(null); + }); + }; + + /** --------------------------- + * 2) Bookmark toggle (AJAX 포함) + * - 기본값 false로 시작 (서버값 무시) + * - 클릭 시 UI만 토글 + * - aria-pressed, class 동기화 + * -------------------------- */ + const getCSRFToken = () => + (document.querySelector("[name=csrfmiddlewaretoken]") || {}).value || + (document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/) || [])[1] || + ""; + + const bindBookmarkAjax = () => { + document.querySelectorAll("[data-bookmark-btn]").forEach((btn) => { + btn.addEventListener("click", async (e) => { + console.log("bookmark btn clicked"); + e.preventDefault(); + e.stopPropagation(); + + // note id 추출 (A안/B안 모두 대응) + const noteId = + btn.dataset.noteId || + btn.closest("[data-note-id]")?.dataset.noteId; + + if (!noteId) { + console.error("noteId not found for bookmark button"); + return; + } + + // 현재 상태 + const wasActive = btn.classList.contains("is-active"); + const next = !wasActive; + + // 옵티미스틱 UI + btn.classList.toggle("is-active", next); + btn.setAttribute("aria-pressed", next ? "true" : "false"); + btn.disabled = true; + + try { + const res = await fetch(`/api/reflections/retrospectives/${noteId}/`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCSRFToken(), + }, + body: JSON.stringify({ bookmarked: next }), + }); + + if (!res.ok) { + const t = await res.text().catch(() => ""); + throw new Error(`PATCH failed: ${res.status} ${t}`); + } + + // 응답이 bookmarked를 내려주면 동기화(선택) + const data = await res.json().catch(() => null); + if (data?.bookmarked !== undefined) { + btn.classList.toggle("is-active", !!data.bookmarked); + btn.setAttribute("aria-pressed", data.bookmarked ? "true" : "false"); + } + + } catch (err) { + console.error(err); + + // 실패 시 롤백 + btn.classList.toggle("is-active", wasActive); + btn.setAttribute("aria-pressed", wasActive ? "true" : "false"); + + alert("북마크 변경 실패"); + } finally { + btn.disabled = false; + } + }); + }); + }; + + + + const bindProjectRoleAutofill = () => { + const projectSel = document.querySelector("[data-project-select]"); + const roleSel = document.querySelector("[data-role-select]"); + const roleMapEl = document.getElementById("roleMapJson"); + + if (!projectSel || !roleSel || !roleMapEl) return; + + let roleMap = {}; + try { + roleMap = JSON.parse(roleMapEl.textContent || "{}"); + } catch { + roleMap = {}; + } + + const setRole = (val) => { + roleSel.value = val || ""; // 개인 회고면 빈값 + }; + + // 초기 상태(수정 페이지 대응): 프로젝트가 없으면 role 비우기 + if (!projectSel.value) setRole(""); + + projectSel.addEventListener("change", () => { + const pid = projectSel.value; + + // ✅ "선택 안 함(개인용)"이면 개인 회고로 리셋 + if (!pid) { + setRole(""); + return; + } + + // ✅ 프로젝트 선택하면 roleMap 기반으로 자동 세팅 + const autoRole = roleMap[pid]; + setRole(autoRole || ""); + }); + }; + + + const bindAssetUpload = () => { + const fileInput = document.getElementById("assetFileInput"); + if (!fileInput) return; + + const wrap = document.querySelector(".ref-form-wrap"); + if (!wrap) return; + + const NOTE_ID = (wrap.dataset.noteId || "").trim(); + const DRAFT_KEY = (wrap.dataset.draftKey || "").trim(); + let currentQid = null; + + const csrfToken = (document.querySelector("[name=csrfmiddlewaretoken]") || {}).value || ""; + + // ✅ URL name은 프로젝트에 맞춰야 함 (기존 _note_form.html에 있던 이름 그대로) + const uploadUrlWithNote = NOTE_ID ? wrap.dataset.uploadNoteUrl : ""; + const uploadUrlTemp = wrap.dataset.uploadTempUrl; + + // 위 2개를 템플릿에서 data로 주는 방식이 제일 안전함. + // (아래 "data-upload-..." 주는 방법 참고) + + const insertAtCursor = (textarea, text) => { + const start = textarea.selectionStart ?? textarea.value.length; + const end = textarea.selectionEnd ?? textarea.value.length; + const before = textarea.value.slice(0, start); + const after = textarea.value.slice(end); + textarea.value = before + text + after; + const pos = start + text.length; + textarea.setSelectionRange(pos, pos); + textarea.focus(); + }; + + document.querySelectorAll("[data-asset-btn]").forEach((btn) => { + btn.addEventListener("click", () => { + currentQid = btn.dataset.qid; + fileInput.value = ""; + fileInput.click(); + }); + }); + + fileInput.addEventListener("change", async () => { + if (!fileInput.files || !fileInput.files[0] || !currentQid) return; + + if (!NOTE_ID && !DRAFT_KEY) { + alert("draft_key가 없어 업로드할 수 없습니다."); + return; + } + + const url = NOTE_ID ? uploadUrlWithNote : uploadUrlTemp; + if (!url) { + alert("업로드 URL이 설정되지 않았습니다."); + return; + } + + const fd = new FormData(); + fd.append("image", fileInput.files[0]); + fd.append("alt_text", "image"); + if (!NOTE_ID) fd.append("draft_key", DRAFT_KEY); + + let res; + try { + res = await fetch(url, { + method: "POST", + headers: { "X-CSRFToken": csrfToken }, + body: fd, + }); + } catch (e) { + console.error(e); + alert("업로드 요청 실패(네트워크)"); + return; + } + + if (!res.ok) { + const t = await res.text().catch(() => ""); + console.error("upload failed:", res.status, t); + alert("업로드 실패"); + return; + } + + const data = await res.json(); + const md = data.md || `![image](${data.url})`; + + const ta = document.getElementById(`ta__${currentQid}`); + if (!ta) return; + + insertAtCursor(ta, (ta.value.endsWith("\n") || ta.value.length === 0) ? md : "\n" + md); + }); + }; + + const bindAssetDelete = () => { + const wrap = document.querySelector(".ref-form-wrap"); + if (!wrap) return; + + const NOTE_ID = (wrap.dataset.noteId || "").trim(); + if (!NOTE_ID) return; + + const csrfToken = (document.querySelector("[name=csrfmiddlewaretoken]") || {}).value || ""; + + document.querySelectorAll("[data-asset-delete]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!confirm("이미지를 삭제할까요?")) return; + + const assetId = btn.dataset.assetId; + const urlTpl = wrap.dataset.deleteAssetUrlTpl; // 예: "/retrospectives/12/assets/0/" 형태 + if (!urlTpl) return; + + const url = urlTpl.replace("/0/", `/${assetId}/`); + + const res = await fetch(url, { + method: "DELETE", + headers: { "X-CSRFToken": csrfToken }, + }); + + if (!res.ok) { + alert("삭제 실패"); + return; + } + + const row = document.querySelector(`[data-asset-row="${assetId}"]`); + if (row) row.remove(); + }); + }); + }; + const bindAutoSubmit = () => { + document.querySelectorAll("[data-auto-submit]").forEach((el) => { + el.addEventListener("change", () => { + el.form?.submit(); + }); + }); + }; + + const insertTableAtCursor = (textarea, rows = 2, cols = 2) => { + console.log("insertTableAtCursor", { rows, cols }); + const headerCells = Array.from({ length: cols }, (_, i) => `헤더${i + 1}`); + const dividerCells = Array.from({ length: cols }, () => "---"); + const bodyRows = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => "내용") + ); + + const lines = [ + `| ${headerCells.join(" | ")} |`, + `| ${dividerCells.join(" | ")} |`, + ...bodyRows.map((row) => `| ${row.join(" | ")} |`), + "", // 마지막 줄바꿈 + ]; + + const table = `\n${lines.join("\n")}`; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const value = textarea.value; + + textarea.value = value.slice(0, start) + table + value.slice(end); + + const newPos = start + table.length; + textarea.selectionStart = textarea.selectionEnd = newPos; + textarea.focus(); + }; + + const bindInsertTable = () => { + const backdrop = document.querySelector(".ref-table-picker-backdrop"); + if (!backdrop) return; + + const sizeText = backdrop.querySelector(".ref-table-picker-size"); + const cancelBtn = backdrop.querySelector(".ref-table-picker-cancel"); + const cells = Array.from(backdrop.querySelectorAll(".ref-table-cell")); + + if (!sizeText || !cancelBtn || cells.length === 0) return; + + let targetTextarea = null; + let hoverRows = 0, hoverCols = 0; + + const reset = () => { + hoverRows = 0; hoverCols = 0; + sizeText.textContent = "0 × 0"; + cells.forEach(c => c.classList.remove("active")); + }; + + const close = () => { + backdrop.hidden = true; + reset(); + targetTextarea = null; + }; + + // 표 버튼 클릭 -> 모달 오픈 + document.addEventListener("click", (e) => { + const btn = e.target.closest("[data-table-btn]"); + if (!btn) return; + + const qid = btn.dataset.qid; + const ta = document.getElementById(`ta__${qid}`); + if (!ta) return; + + targetTextarea = ta; + reset(); + backdrop.hidden = false; + }); + + // hover -> 미리보기(하이라이트) + cells.forEach((cell) => { + cell.addEventListener("mouseenter", () => { + hoverRows = +cell.dataset.rows; + hoverCols = +cell.dataset.cols; + + cells.forEach((c) => { + c.classList.toggle( + "active", + +c.dataset.rows <= hoverRows && +c.dataset.cols <= hoverCols + ); + }); + + sizeText.textContent = `${hoverRows} × ${hoverCols}`; + }); + + // ✅ click -> 즉시 삽입 + cell.addEventListener("click", () => { + const r = +cell.dataset.rows; + const c = +cell.dataset.cols; + if (!targetTextarea || !r || !c) return; + + insertTableAtCursor(targetTextarea, r, c); + close(); + }); + }); + + // 취소/바깥/ESC 닫기 + cancelBtn.addEventListener("click", close); + + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(); + }); + + document.addEventListener("keydown", (e) => { + if (!backdrop.hidden && e.key === "Escape") close(); + }); + }; + + + const bindBookmarkFilter = () => { + const btn = document.querySelector("[data-bookmark-filter]"); + if (!btn) return; + + btn.addEventListener("click", () => { + const url = new URL(window.location.href); + const isOn = url.searchParams.get("bookmarked"); + + if (isOn) { + url.searchParams.delete("bookmarked"); + } else { + url.searchParams.set("bookmarked", "1"); + } + + window.location.href = url.toString(); + }); + }; + + const bindMDCopyBtn = () => { + document.querySelectorAll("[data-md-copy]").forEach((btn) => { + btn.addEventListener("click", async () => { + const ta = document.getElementById(btn.dataset.targetId); + if (!ta) return; + + try { + await navigator.clipboard.writeText(ta.value); + showToast("마크다운 복사 완료", "success"); + } catch (e) { + console.error(e); + showToast("마크다운 복사 실패", "error"); + } + }); + }); + }; + + const bindAutoResizeTextarea = () => { + const resize = (ta) => { + ta.style.height = "auto"; + ta.style.height = ta.scrollHeight + "px"; + }; + + document.querySelectorAll(".ref-textarea").forEach((ta) => { + // 초기 값 반영 (수정 페이지 대응) + resize(ta); + + ta.addEventListener("input", () => resize(ta)); + }); + }; + + + + /** --------------------------- + * Init + * -------------------------- */ + const init = () => { + bindMenus(); + bindBookmarkAjax(); + bindProjectRoleAutofill(); + bindAssetUpload(); + bindAssetDelete(); + bindAutoSubmit(); + bindBookmarkFilter(); + bindInsertTable(); + bindMDCopyBtn(); + bindAutoResizeTextarea(); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/static/js/team_apply.js b/static/js/team_apply.js new file mode 100644 index 0000000..84d267b --- /dev/null +++ b/static/js/team_apply.js @@ -0,0 +1,67 @@ +const showToast = (message, type = "success") => { + const toast = document.createElement("div"); + toast.className = `ref-toast ref-toast-${type}`; + toast.textContent = message; + + Object.assign(toast.style, { + position: "fixed", + bottom: "50px", + left: "50%", + transform: "translateX(-50%) translateY(20px)", + background: type === "success" ? "#4272EF" : "#FF6B6B", + color: "#fff", + padding: "12px 25px", + borderRadius: "30px", + fontSize: "15px", + fontWeight: "500", + boxShadow: "0 4px 15px rgba(0,0,0,0.2)", + opacity: "0", + transition: "all 0.3s ease", + zIndex: "9999", + }); + + document.body.appendChild(toast); + + requestAnimationFrame(() => { + toast.style.opacity = "1"; + toast.style.transform = "translateX(-50%) translateY(0)"; + }); + + setTimeout(() => { + toast.style.opacity = "0"; + toast.style.transform = "translateX(-50%) translateY(20px)"; + setTimeout(() => toast.remove(), 300); + }, 2000); +}; + +const bindNotifyBtn = () => { + document.querySelectorAll(".noti_button").forEach(btn => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + + const form = btn.closest("form"); + + fetch(form.action, { + method: "POST", + body: new FormData(form), + }) + .then(res => { + if (res.ok) { + showToast("알림 신청이 완료되었습니다.", "success"); + btn.disabled = true; + btn.textContent = "신청 완료"; + btn.style.background = "#ccc"; + } else { + showToast("신청 중 오류가 발생했습니다.", "error"); + } + }) + .catch(() => showToast("네트워크 오류가 발생했습니다.", "error")); + }); + }); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bindNotifyBtn); +} else { + bindNotifyBtn(); +} \ No newline at end of file diff --git a/templates/account/email/email_confirmation_message.txt b/templates/account/email/email_confirmation_message.txt new file mode 100644 index 0000000..7cd3409 --- /dev/null +++ b/templates/account/email/email_confirmation_message.txt @@ -0,0 +1,11 @@ +안녕하세요, KITUP입니다. + +아래 링크를 클릭하시면 이메일 인증이 완료됩니다. + +{{ activate_url }} + +본인이 요청한 것이 아니라면 이 메일을 무시해주세요. + +감사합니다. + +KITUP 팀 드림 diff --git a/templates/account/email/email_confirmation_subject.txt b/templates/account/email/email_confirmation_subject.txt new file mode 100644 index 0000000..940c865 --- /dev/null +++ b/templates/account/email/email_confirmation_subject.txt @@ -0,0 +1 @@ +이메일 인증을 완료해주세요 \ No newline at end of file diff --git a/templates/account/email_confirm.html b/templates/account/email_confirm.html new file mode 100644 index 0000000..1fac920 --- /dev/null +++ b/templates/account/email_confirm.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}이메일 인증 완료 - KITUP{% endblock %} + +{% block content %} +
+
+ {% if confirmation %} +

✅ 이메일 인증 완료!

+

{{ confirmation.email_address.email }}

+

이메일 인증이 완료되었습니다.

+

이제 로그인하실 수 있습니다.

+ + 로그인하기 + {% else %} +

❌ 인증 실패

+

유효하지 않거나 만료된 인증 링크입니다.

+ 다시 가입하기 + {% endif %} +
+
+ + +{% endblock %} diff --git a/templates/account/level_test.html b/templates/account/level_test.html new file mode 100644 index 0000000..8700afd --- /dev/null +++ b/templates/account/level_test.html @@ -0,0 +1,545 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}KITUP - 레벨테스트{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+ + + + + +
+
+
+
+ +
+ 1/10 +

+
+ +
+
+ +
+ +
+ + + +
+
+
+ + + +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/account/login.html b/templates/account/login.html new file mode 100644 index 0000000..e92e822 --- /dev/null +++ b/templates/account/login.html @@ -0,0 +1,85 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}KITUP - 로그인{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/account/mypage.html b/templates/account/mypage.html new file mode 100644 index 0000000..08fd704 --- /dev/null +++ b/templates/account/mypage.html @@ -0,0 +1,141 @@ +{% extends 'base.html' %} +{% load static %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+ + +
+
+

기술 스택

+
+ {% for tech in user_obj.tech_stacks.all %} + # {{ tech.name }} + {% empty %} +

등록된 스택이 없습니다.

+ {% endfor %} +
+
+ +
+ {% for membership in memberships %} +
+

팀 프로젝트 기록

+

{{ membership.team.project.title }}

+ {{ membership.role.name }} +
+ {% empty %} +
+

팀 프로젝트 기록

+

아직 참여한 프로젝트가 없습니다.

+
+ {% endfor %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/account/onboarding_profile.html b/templates/account/onboarding_profile.html new file mode 100644 index 0000000..adc896a --- /dev/null +++ b/templates/account/onboarding_profile.html @@ -0,0 +1,164 @@ +{% extends 'base.html' %} {% load static %} {% block header %} + +{% endblock %} {% block content %} + +

{% if user.nickname %}프로필 수정{% else %}프로필 설정{% endif %}

+ +
+ {% csrf_token %} + +
+ {{ form.nickname.errors }} + {{ form.nickname.label_tag }} +
+ {{ form.nickname }} + +
+ +
+ +
+ {{ form.github_id.errors }} + {{ form.github_id.label_tag }} {{ form.github_id }} +
+ +
+ {{ form.profile_image.errors }} + {{ form.profile_image.label_tag }} {{ form.profile_image }} +
+ +
+ {{ form.tech_stacks.errors }} + +
+ {{ form.tech_stacks }} +
+ {{ form.tech_stacks.help_text }} +
+ + +
+ + {% endblock %} \ No newline at end of file diff --git a/templates/account/password_reset.html b/templates/account/password_reset.html new file mode 100644 index 0000000..c880c97 --- /dev/null +++ b/templates/account/password_reset.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}비밀번호 초기화 - KITUP{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+

비밀번호 초기화

+

등록된 이메일을 입력하면 초기화 링크를 발송합니다

+
+ +
+ {% csrf_token %} + +
+ + {{ form.email }} + {% if form.email.errors %} +
+ {{ form.email.errors }} +
+ {% endif %} +
+ + {% if form.non_field_errors %} + + {% endif %} + + +
+ + +
+
+
+
+{% endblock %} diff --git a/templates/account/password_reset_done.html b/templates/account/password_reset_done.html new file mode 100644 index 0000000..e17840a --- /dev/null +++ b/templates/account/password_reset_done.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}이메일 발송 완료 - KITUP{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+ ✓ +
+

비밀번호 초기화 이메일을 발송했습니다

+

등록하신 이메일 주소로 비밀번호 초기화 링크를 발송했습니다.

+ +
+

다음 단계:

+
    +
  1. 이메일함을 확인하세요
  2. +
  3. KITUP의 비밀번호 초기화 이메일을 찾으세요
  4. +
  5. 이메일의 링크를 클릭하여 새 비밀번호를 설정하세요
  6. +
+
+ +
+ 💡 팁: +
    +
  • 이메일이 보이지 않으면 스팸 폴더를 확인하세요
  • +
  • 초기화 링크는 24시간 동안 유효합니다
  • +
  • 링크 재발송이 필요하면 다시 시도하세요
  • +
+
+ + 로그인으로 돌아가기 +
+
+
+
+{% endblock %} diff --git a/templates/account/password_reset_email.html b/templates/account/password_reset_email.html new file mode 100644 index 0000000..4687a2a --- /dev/null +++ b/templates/account/password_reset_email.html @@ -0,0 +1,137 @@ + + + + + + KITUP 비밀번호 초기화 + + + + + + diff --git a/templates/account/password_reset_email.txt b/templates/account/password_reset_email.txt new file mode 100644 index 0000000..7016444 --- /dev/null +++ b/templates/account/password_reset_email.txt @@ -0,0 +1,14 @@ +{{ user.nickname }}님께, + +{{ site_name }} 계정의 비밀번호 초기화를 요청하셨습니다. + +아래 링크를 클릭하여 새로운 비밀번호를 설정하세요: + +{{ reset_url }} + +이 링크는 24시간 동안 유효합니다. + +혹시 이 요청을 하지 않으셨다면 이 이메일을 무시하셔도 됩니다. + +감사합니다, +{{ site_name }} 팀 diff --git a/templates/account/password_reset_from_key.html b/templates/account/password_reset_from_key.html new file mode 100644 index 0000000..9fc9cdc --- /dev/null +++ b/templates/account/password_reset_from_key.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}새 비밀번호 설정 - KITUP{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+

새 비밀번호 설정

+

안전한 새 비밀번호를 입력하세요

+
+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+ + {{ form.password1 }} + {% if form.password1.errors %} +
+ {{ form.password1.errors }} +
+ {% endif %} +
+ 비밀번호 요구사항: +
    +
  • 최소 8자 이상
  • +
  • 대문자, 소문자, 숫자를 포함해야 합니다
  • +
  • 이전 비밀번호와 달라야 합니다
  • +
+
+
+ +
+ + {{ form.password2 }} + {% if form.password2.errors %} +
+ {{ form.password2.errors }} +
+ {% endif %} +
+ + +
+ + +
+
+
+
+{% endblock %} diff --git a/templates/account/password_reset_from_key_done.html b/templates/account/password_reset_from_key_done.html new file mode 100644 index 0000000..017fc6b --- /dev/null +++ b/templates/account/password_reset_from_key_done.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}비밀번호 변경 완료 - KITUP{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+ ✓ +
+

비밀번호가 변경되었습니다

+

새 비밀번호로 성공적으로 변경되었습니다.

+ +
+

이제 새 비밀번호로 로그인하실 수 있습니다.

+
+ + 로그인 +
+
+
+
+{% endblock %} diff --git a/templates/account/profile_edit.html b/templates/account/profile_edit.html new file mode 100644 index 0000000..edad991 --- /dev/null +++ b/templates/account/profile_edit.html @@ -0,0 +1,226 @@ +{% extends 'base.html' %} +{% load static %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+ + +
+
+

기술 스택

+
+ {% for tech in user.tech_stacks.all %} + + # {{ tech.name }} + + {% empty %} +

등록된 기술 스택이 없습니다.

+ {% endfor %} +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/account/signup.html b/templates/account/signup.html new file mode 100644 index 0000000..eefe330 --- /dev/null +++ b/templates/account/signup.html @@ -0,0 +1,116 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}회원가입 - KITUP{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+ +
+ + + +{% endblock %} diff --git a/templates/account/test_result.html b/templates/account/test_result.html new file mode 100644 index 0000000..97ec8c5 --- /dev/null +++ b/templates/account/test_result.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}KITUP - 레벨 진단 결과{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+ +

WEB {{ role_name }} 레벨 진단

+ + +
+ +
+ {% if level == 1 %} +
🌱
+ {% elif level == 2 %} +
🌿
+ {% elif level == 3 %} +
🌳
+ {% else %} +
🏆
+ {% endif %} +
+ + +
Lv{{ level }}
+ + +
+

{{ user.nickname }} 님은 {{ level_description }}입니다.

+

프로젝트를 시작해 보세요!

+
+
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/account/verification_sent.html b/templates/account/verification_sent.html new file mode 100644 index 0000000..65ee073 --- /dev/null +++ b/templates/account/verification_sent.html @@ -0,0 +1,63 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}이메일 인증 - KITUP{% endblock %} + +{% block content %} +
+
+

이메일 인증

+

입력하신 이메일 주소로 인증 메일을 발송했습니다.

+

이메일을 확인하고 인증 링크를 클릭해주세요.

+ +
+

✓ 메일이 오지 않았다면 스팸함을 확인해주세요.

+

✓ 인증 링크는 3일간 유효합니다.

+
+ + 로그인 페이지로 +
+
+ + +{% endblock %} diff --git a/templates/account/withdraw.html b/templates/account/withdraw.html new file mode 100644 index 0000000..6072f80 --- /dev/null +++ b/templates/account/withdraw.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+

정말 탈퇴하시겠습니까?

+ +

+ 그동안 활동하신 모든 기록이 삭제되며,
+ 이 작업은 되돌릴 수 없습니다. +

+ +
+ {% csrf_token %} +
+ + 취소 + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..681818b --- /dev/null +++ b/templates/base.html @@ -0,0 +1,60 @@ +{% load static %} + + + + + + {% block title %}KITUP{% endblock %} + + {% block header %}{% endblock %} + + + +
+ +
+ +

KITUP

+
+ + +
+ + + +
+ +
+ {% if user.is_authenticated %} +

팀매칭

+ +

KITUP 프로젝트

+

회고

+

마이페이지

+

로그아웃

+ {% else %} +

로그인

+

회원가입

+ {% endif %} +
+
+ +
+ {% block content %} + {% endblock %} +
+ +
+ {% block footer %} + {% endblock %} +
+ + + + \ No newline at end of file diff --git a/templates/emails/matching_result.html b/templates/emails/matching_result.html new file mode 100644 index 0000000..7477168 --- /dev/null +++ b/templates/emails/matching_result.html @@ -0,0 +1,74 @@ + + + + + + + +
+ +
+

🎉 팀 매칭 완료

+
+ + +
+
+ 안녕하세요, {{ user.nickname }}님! 👋 +
+ +

+ {{ season.name }} 팀 매칭이 완료되었습니다!
+ 함께할 팀원들을 소개합니다. +

+ + +
+

📅 프로젝트 기간

+

{{ project_start }} ~ {{ project_end }}

+
+ + +
+

👥 당신의 팀

+ + + + + + + + + + {% for member in team_members %} + + + + + + {% endfor %} + +
닉네임역할레벨
{{ member.user.nickname }} + {% if member.role.code == "PM" %} + 기획자 (PM) + {% elif member.role.code == "FRONTEND" %} + 프론트엔드 (FE) + {% elif member.role.code == "BACKEND" %} + 백엔드 (BE) + {% endif %} + Lv{{ member.level }}
+
+ +

+ KITUP 대시보드에서 팀 정보와 프로젝트 세부사항을 확인할 수 있습니다. +

+
+ + + +
+ + diff --git a/templates/emails/matching_result.txt b/templates/emails/matching_result.txt new file mode 100644 index 0000000..1c3405d --- /dev/null +++ b/templates/emails/matching_result.txt @@ -0,0 +1,27 @@ +팀 매칭 완료 + +안녕하세요, {{ user.nickname }}님! + +{{ season.name }} 팀 매칭이 완료되었습니다. +함께할 팀원들을 소개합니다. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📅 프로젝트 기간 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{{ project_start }} ~ {{ project_end }} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +👥 당신의 팀 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[이름] [역할] [레벨] +{% for member in team_members %} +{{ member.user.nickname|ljust:18 }} {% if member.role.code == "PM" %}기획자 (PM){% elif member.role.code == "FRONTEND" %}프론트엔드 (FE){% elif member.role.code == "BACKEND" %}백엔드 (BE){% endif %}{% if member.role.code == "PM" %} {% elif member.role.code == "FRONTEND" %} {% elif member.role.code == "BACKEND" %} {% endif %} Lv{{ member.level }} +{% endfor %} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +KITUP 대시보드에서 팀 정보와 프로젝트 세부사항을 확인할 수 있습니다. + +이 이메일은 KITUP에서 자동으로 발송된 메일입니다. +© 2026 KITUP. All rights reserved. diff --git a/templates/emails/matching_start.html b/templates/emails/matching_start.html new file mode 100644 index 0000000..c8efa00 --- /dev/null +++ b/templates/emails/matching_start.html @@ -0,0 +1,51 @@ + + + + + + + +
+
+

🎯 팀 매칭 기간이 시작되었습니다!

+
+ +
+
+ 안녕하세요, {{ user.nickname }}님! 👋 +
+ +

+ {{ season.name }} 팀 매칭 기간이 시작되었습니다!
+ 이제 당신과 함께 프로젝트를 할 팀원들을 찾을 수 있습니다. +

+ +
+

📅 팀 매칭 기간

+

{{ matching_start }} ~ {{ matching_end }}

+
+ +
+

💡 팀 매칭 신청 방법

+
    +
  1. KITUP 앱에 접속하세요
  2. +
  3. 열정 레벨 테스트를 작성하세요
  4. +
  5. 원하는 역할을 선택하세요
  6. +
  7. 매칭이 완료될 때까지 기다려주세요
  8. +
+
+ +

+ ⚠️ 주의사항:
+ 열정 레벨 테스트를 작성하지 않으면 팀 매칭에 참여할 수 없습니다.
+ 기간 내에 신청을 완료해주세요! +

+
+ + +
+ + diff --git a/templates/emails/matching_start.txt b/templates/emails/matching_start.txt new file mode 100644 index 0000000..690a5ef --- /dev/null +++ b/templates/emails/matching_start.txt @@ -0,0 +1,32 @@ +팀 매칭 기간이 시작되었습니다! + +안녕하세요, {{ user.nickname }}님! + +{{ season.name }} 팀 매칭 기간이 시작되었습니다. +이제 당신과 함께 프로젝트를 할 팀원들을 찾을 수 있습니다. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📅 팀 매칭 기간 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{{ matching_start }} ~ {{ matching_end }} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💡 팀 매칭 신청 방법 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +1. KITUP 앱에 접속하세요 +2. 열정 레벨 테스트를 작성하세요 +3. 원하는 역할을 선택하세요 +4. 매칭이 완료될 때까지 기다려주세요 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ 주의사항 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +열정 레벨 테스트를 작성하지 않으면 팀 매칭에 참여할 수 없습니다. +기간 내에 신청을 완료해주세요! + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +이 이메일은 KITUP에서 자동으로 발송된 메일입니다. +© 2026 KITUP. All rights reserved. diff --git a/templates/guides/mission.html b/templates/guides/mission.html new file mode 100644 index 0000000..77384ed --- /dev/null +++ b/templates/guides/mission.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% load static %} + +{% block header %} + +{% endblock %} + +{% block content %} +{% if not project %} +
+

진행 중인 프로젝트가 없습니다.

+
+{% else %} + +
+

DASHBOARD

+

MISSION

+
+ +
+
+ rocket +
+

{{ project.title }}의 진척도

+

*파트별로 진행 속도를 맞추는 것을 권장드려요!

+
+
+ + {% for progress in all_role_progress %} +
+

+ {% if progress.role.code == "PM" %}PM + {% elif progress.role.code == "FRONTEND" %}FE + {% elif progress.role.code == "BACKEND" %}BE + {% endif %} +

+
+
+
+
+ {{ progress.progress_percent }}% +
+ {% endfor %} +
+ +{% for mission in mission_data %} +
+
+
{{ forloop.counter }}
+
+
+
+
+
+

{{ mission.card.title }}

+ {% if mission.is_completed %} + 체크 + {% else %} + 체크 + {% endif %} +
+
+
+
    + {% for task_item in mission.task_progress_data %} +
  • + {{ task_item.task.title }} + {% if task_item.task.description %} +
    {{ task_item.task.description }} + {% endif %} +
  • + {% endfor %} +
+
+
+
+
+
+{% endfor %} + +{% endif %} +{% if not project %} +
+{% else %} + +{% endif %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/main.html b/templates/main.html new file mode 100644 index 0000000..20f698c --- /dev/null +++ b/templates/main.html @@ -0,0 +1,316 @@ +{% extends 'base.html' %} {% load static %} {% block header %} + +{% endblock %} {% block content %} +
+ 메인이미지 +
+
+
+ 회고이미지 +

오늘의 작업을 기록해보세요.

+
+
+ {% if user.is_authenticated %} + {% if recent_reflections %} +
+ {% for reflection in recent_reflections %} + + {% endfor %} +
+ {% else %} +

작성된 기록이 없습니다.

+ {% endif %} + {% else %} +

로그인 후 이용해보세요.

+ {% endif %} +
+ {% if user.is_authenticated %} + + {% else %} + {% endif %} +
+
+ {% if season.status != 'MATCHING' %} +

지금은 팀 매칭 모집 기간이 아니예요.

+ + {% elif user_obj.passion_level == None %} +

팀 매칭 모집이 시작됐어요

+

+ 6주의 기간동안 희망하는 스택으로 프로젝트를 진행하고, 실력을 쌓아보아요. +

+
+
+

WEB 기획

+ {% if user.is_authenticated %} + {% with pm_level=role_levels.PM %} + {% if pm_level == 1 %} + Level1 +

Lv1

+ + {% elif pm_level == 2 %} + Level2 +

Lv2

+ + {% elif pm_level == 3 %} + Level3 +

Lv3

+ + {% elif pm_level == 4 %} + Level4 +

Lv4

+ + {% else %} + nolevel +

아직 레벨 진단이 완료되지 않았어요!

+ + {% endif %} + {% endwith %} + {% else %} + nolevel +

로그인 후 레벨을 확인해보세요.

+ + {% endif %} +
+
+

WEB 프론트엔드

+ {% if user.is_authenticated %} + {% with fe_level=role_levels.FRONTEND%} + {% if fe_level == 1 %} + Level1 +

Lv1

+ + {% elif fe_level == 2 %} + Level2 +

Lv2

+ + {% elif fe_level == 3 %} + Level3 +

Lv3

+ + {% elif fe_level == 4 %} + Level4 +

Lv4

+ + {% else %} + nolevel +

아직 레벨 진단이 완료되지 않았어요!

+ + {% endif %} + {% endwith %} + {% else %} + nolevel +

로그인 후 레벨을 확인해보세요.

+ + {% endif %} +
+
+

WEB 백엔드

+ {% if user.is_authenticated %} + {% with be_level=role_levels.BACKEND%} + {% if be_level == 1 %} + Level1 +

Lv1

+ + {% elif be_level == 2 %} + Level2 +

Lv2

+ + {% elif be_level == 3 %} + Level3 +

Lv3

+ + {% elif be_level == 4 %} + Level4 +

Lv4

+ + {% else %} + nolevel +

아직 레벨 진단이 완료되지 않았어요!

+ + {% endif %} + {% endwith %} + {% else %} + nolevel +

로그인 후 레벨을 확인해보세요.

+ + {% endif %} +
+
+ + + {% elif season.status == 'IN_PROJECT' %} +
+

팀 매칭 결과가 발표됐어요.

+

+ 6주의 기간동안 비슷한 실력과 열정을 가진 팀원들과 멋진 서비스를 + 완성해보아요. +

+

결과 보러가기

+
+ + + {% else %} +

팀 매칭 신청이 완료되었어요.

+ + {% endif %} +
+
+
+ project_img +

KITUP 프로젝트

+
+
+ {% if archived_projects %} +
+ {% for project in archived_projects %} +
+ {% if project.project_image %} + {{ project.title }} + {% else %} + 예시이미지 + {% endif %} +

서비스명

+

서비스명은 이러이러한 기능을 하는 플랫폼이다

+
+ {% endfor %} +
+ {% else %} +

진행된 KITUP 프로젝트가 없습니다.

+ {% endif %} +
+
+{% endblock %} +{% block footer %} +

KitUp 개인정보처리방침

+
+

Copyrightⓒ2026.KITUP. All rights reserved. +

+{% endblock %} \ No newline at end of file diff --git a/templates/privacy-policy.html b/templates/privacy-policy.html new file mode 100644 index 0000000..0d57acb --- /dev/null +++ b/templates/privacy-policy.html @@ -0,0 +1,158 @@ +{% extends 'base.html' %} +{% load static %} +{% block header %} + +{% endblock %} +{% block content %} +
+

KITUP 개인정보처리방침

+

제1조 (목적)

+

+ 팀 군고구마(이하 ‘팀’이라고 함)는 팀이 제공하고자 하는 서비스(이하 + ‘KITUP 서비스’ 또는 ‘서비스’)를 이용하는 개인(이하 ‘이용자’ 또는 + ‘개인’)의 정보(이하 ‘개인정보’)를 보호하기 위해, 개인정보보호법 등 관련 + 법령을 준수하고, 서비스 이용자의 개인정보 보호 관련한 고충을 신속하고 + 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 개인정보처리방침(이하 + ‘본 방침’)을 수립·공개합니다. +

+
+

제2조 (개인정보 처리의 원칙)

+

+ 개인정보 관련 법령 및 본 방침에 따라 팀은 이용자의 개인정보를 수집할 수 + 있으며 수집된 개인정보는 개인의 동의가 있는 경우에 한해 제3자에게 제공될 + 수 있습니다. 단, 법령의 규정 등에 의해 적법하게 강제되는 경우 팀은 + 수집한 이용자의 개인정보를 사전에 개인의 동의 없이 제3자에게 제공할 수도 + 있습니다. +

+
+

제3조 (본 방침의 공개)

+

+ 1. 팀은 이용자가 언제든지 쉽게 본 방침을 확인할 수 있도록 KITUP 홈페이지 + 첫 화면 또는 첫 화면과의 연결 화면을 통해 본 방침을 공개하고 있습니다. +
+ 2. 팀은 제1항에 따라 본 방침을 공개하는 경우 글자 크기, 색상 등을 + 활용하여 이용자가 본 방침을 쉽게 확인할 수 있도록 합니다. +

+
+

제4조 (본 방침의 변경)

+

+ 본 방침은 개인정보 관련 법령, 지침, 고시 또는 정부나 KITUP 서비스의 + 정책이나 내용의 변경에 따라 개정될 수 있습니다. + 1. 팀은 제1항에 따라 본 방침을 개정하는 경우 다음 각 호 하나 이상의 + 방법으로 공지합니다. +
+ 가. 팀이 운영하는 인터넷 홈페이지의 첫 화면 + 공지사항란 또는 별도의 창을 통하여 공지하는 방법 +
+ 나. 서면·모사전송·전자우편 또는 이와 비슷한 방법으로 이용자에게 공지하는 + 방법 +
+ 2. 팀은 제2항의 공지를 본 방침 개정 시행일로부터 최소 7일 이전에 + 공지합니다. 다만, 이용자 권리에 중요한 변경이 있을 경우에는 최소 30일 + 전에 공지합니다. +

+
+

제5조 (처리하는 개인정보 항목)

+

+ 팀은 이용자의 서비스 회원가입을 위하여 다음과 같은 정보를 수집합니다. + - 필수 수집 정보: 이메일 주소, 비밀번호, 이름 및 닉네임 + 팀은 이용자의 본인 인증을 위하여 다음과 같은 정보를 수집합니다. + - 필수 수집 정보: 이메일 주소 + 인터넷 서비스 이용 과정에서 아래 개인정보 항목이 자동으로 생성되어 수집될 수 있습니다. + - IP 주소, 쿠키, MAC 주소, 서비스 이용 기록, 방문 기록, 불량 이용 기록 등 +

+
+

제6조 (개인정보 처리 목적 및 방법)

+

+ 팀은 다음과 같은 방법으로 이용자의 개인정보를 수집합니다. + 1. 이용자가 팀의 홈페이지에 자신의 개인정보를 입력하는 방식
+ 2. 어플리케이션 등 팀이 제공하는 홈페이지 외의 서비스를 통해 이용자가 자신의 개인정보를 입력하는 방식
+ 3. 이용자가 팀이 발송한 이메일을 수신하여 개인정보를 입력하는 방식
+ 4. 이용자가 고객센터 상담, 게시판 활동 등 팀의 서비스를 이용하는 과정에서 입력하는 방식

+ 팀은 개인정보를 다음 각 호의 경우에 이용합니다. + 1. 공지사항 전달 등 서비스 운영에 필요한 경우
+ 2. 이용 문의 회신, 불만 처리 등 서비스 개선을 위한 경우
+ 3. KITUP 서비스를 제공하기 위한 경우
+ 4. 법령 및 이용약관을 위반하는 회원에 대한 이용 제한 조치 및 부정 이용 방지를 위한 경우
+ 5. 개인정보 및 관심에 기반한 이용자 간 관계 형성을 위한 경우
+

+
+

제7조 (개인정보의 보유 및 이용기간)

+

+ 1. 팀은 개인정보 수집·이용 목적 달성을 위한 기간 동안 개인정보를 보유 및 이용합니다.
+ 2. 전항에도 불구하고 서비스 부정 이용 기록은 부정 가입 및 이용 방지를 위하여 회원 탈퇴 시점으로부터 최대 1년간 보관합니다. +

+
+

제8조 (개인정보의 파기)

+

+ 팀은 개인정보 처리 목적 달성, 보유·이용 기간 경과 등 개인정보가 불필요해진 경우 지체 없이 해당 정보를 파기합니다. + 1. 이용자가 입력한 정보는 처리 목적 달성 후 별도의 DB로 옮겨지거나 내부 방침 및 관련 법령에 따라 일정 기간 보관 후 파기됩니다.
+ 2. 팀은 파기 사유 발생 시 개인정보 보호책임자의 승인 절차를 거쳐 파기합니다. +

+ 파기방법 + 전자적 파일은 복구할 수 없는 기술적 방법으로 삭제하며, 종이 문서는 분쇄 또는 소각합니다. +

+
+

제9조 (아동의 개인정보 보호)

+

팀은 만 14세 미만 아동의 개인정보 보호를 위하여 만 14세 이상의 이용자에 한하여 회원가입을 허용합니다.

+
+

제10조 (개인정보 조회 및 수집 동의 철회)

+

+ 1. 이용자 및 법정대리인은 언제든지 자신의 개인정보를 조회·수정하거나 수집 동의 철회를 요청할 수 있습니다. +
+ 2. 개인정보 보호책임자에게 이메일 등으로 요청할 경우 팀은 지체 없이 조치합니다. +

+
+

제11조 (개인정보 정정)

+

+ 1. 이용자는 개인정보 오류에 대해 정정을 요청할 수 있습니다. +
+ 2. 정정 완료 전까지 해당 개인정보는 이용 또는 제공하지 않으며, 제3자에게 제공된 경우 지체 없이 정정 사실을 통지합니다. +

+
+

제12조 (이용자의 의무)

+

+ 1. 이용자는 자신의 개인정보를 최신 상태로 유지해야 하며, 부정확한 정보 입력에 대한 책임은 이용자에게 있습니다. +
+ 2. 타인 정보 도용 시 자격 상실 또는 법적 처벌을 받을 수 있습니다. +
+ 3. 이메일과 비밀번호 보안 유지 책임은 이용자에게 있습니다. +

+
+

제13조 (개인정보 보호 및 구제)

+

+ (이하 내용 동일 유지) +

+
+

제14조 (개인정보의 안전성 확보조치)

+

+ (내용 동일 유지) +

+
+

제15조 (개인정보 보호책임자)

+

+ - 이메일: [kitup.dev@gmail.com](mailto:kitup.dev@gmail.com) +
+ - 문의 방법: 이메일 문의 +

+
+

제16조 (개인정보 처리의 위탁)

+

+ (내용 동일 유지) +

+
+

제17조 (소셜 로그인)

+

+ (내용 동일 유지) +

+
+

부칙

+

+ 본 방침은 2026년 2월 13일부터 시행됩니다. +

+ 홈으로 +
+ +{% endblock %} diff --git a/templates/projects/dashboard.html b/templates/projects/dashboard.html new file mode 100644 index 0000000..8666666 --- /dev/null +++ b/templates/projects/dashboard.html @@ -0,0 +1,188 @@ +{% extends 'base.html' %} +{% load static %} +{% load markdown_tags %} +{% block header %} + +{% endblock %} +{% block content %} +{% if has_project == False %} +
+

진행 중인 프로젝트가 없어요.

+

메인화면으로 이동

+
+{% else %} +
+

DASHBOARD

+

MISSION

+
+
+
+ {% if project.project_image %} + {{ project.title }} + {% else %} + 서비스이미지 + {% endif %} +

{{ project.title }}

+

{{ project.description|default:"프로젝트 설명이 없습니다." }}

+
+
+ 달력이미지 +

진행기간 + {% if project.starts_at and project.ends_at %} + {{ project.starts_at|date:"Y/m/d" }} ~ {{ project.ends_at|date:"Y/m/d" }} + {% else %} + 미정 + {% endif %} +

+
+ +
+
+
+ 팀 아이콘 +

Team

+ +
+
+

PM + {% for member in members %} + {% if member.role.code == "PM" %} + {{ member.user.username }} + {% endif %} + {% endfor %}

+

FE + {% for member in members %} + {% if member.role.code == "FRONTEND" %} + {{ member.user.username }} + {% endif %} + {% endfor %} +

+

BE + {% for member in members %} + {% if member.role.code == "BACKEND" %} + {{ member.user.username }} + {% endif %} + {% endfor %} +

+
+ {% for item in members_with_level %} +
+
+ {% if item.member.user.profile_image %} + 프로필사진 + {% else %} + 프로필사진 + {% endif %} + {% if item.level == 1 %} + 레벨1 + {% elif item.level == 2 %} + 레벨2 + {% elif item.level == 3 %} + 레벨3 + {% elif item.level == 4 %} + 레벨4 + {% endif %} +
+

{{ item.member.user.nickname|default:item.member.user.username }}

+

✉️ {{ item.member.user.email }}

+ {% if item.member.user.github_id %} +

🖥️ @{{ item.member.user.github_id }}

+ {% endif %} + {% if item.member.role.code == "PM" %} +

기획자

+ {% elif item.member.role.code == "FRONTEND" %} +

프론트엔드

+ {% elif item.member.role.code == "BACKEND" %} +

백엔드

+ {% endif %} +
+ {% endfor %} +
+
+
+
+
+ rule +

팀 규칙

+
+
+ {% if project.team_rules %} + {{ project.team_rules|markdown|safe }} + {% else %} +

팀 규칙이 없습니다.

+ {% endif %} +
+
+ +
+
+ progress +
+

{{ project.title }}의 진척도

+
+
+
+

전체

+
+
+
+ {% if guide_progress %}{{ guide_progress.progress_percent }}{% else %}0{% endif %}% +
+
+ {% if is_team_member %} + + + + {% endif %} + {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/projects/dashboard_update.html b/templates/projects/dashboard_update.html new file mode 100644 index 0000000..b0cd23c --- /dev/null +++ b/templates/projects/dashboard_update.html @@ -0,0 +1,147 @@ +{% extends 'base.html' %} +{% load static %} +{% block header %} + +{% endblock %} +{% block content %} +
+

DASHBOARD

+

MISSION

+
+
+
+ {% csrf_token %} + +
+ + + + +
+ +
+ 달력이미지 +

진행기간 + {% if project.starts_at and project.ends_at %} + {{ project.starts_at|date:"Y/m/d" }} ~ {{ project.ends_at|date:"Y/m/d" }} + {% else %} + 미정 + {% endif %}

+
+ + +
+ +
+
+ 팀 아이콘 +

Team

+ +
+
+

PM + {% for member in members %} + {% if member.role.code == "PM" %} + {{ member.user.username }} + {% endif %} + {% endfor %} +

+

FE + {% for member in members %} + {% if member.role.name == "프론트엔드" %} + {{ member.user.username }} + {% endif %} + {% endfor %} +

+

BE + {% for member in members %} + {% if member.role.name == "백엔드" %} + {{ member.user.username }} + {% endif %} + {% endfor %} +

+
+ {% for item in members_with_level %} +
+
+ {% if item.member.user.profile_image %} + 프로필사진 + {% else %} + 프로필사진 + {% endif %} + + {% if item.level == 1 %} + 레벨1 + {% elif item.level == 2 %} + 레벨2 + {% elif item.level == 3 %} + 레벨3 + {% elif item.level == 4 %} + 레벨4 + {% endif %} +
+ +

{{ item.member.user.username }}

+

✉️ {{ item.member.user.email }}

+

🖥️ @{{ item.member.user.username }}

+ {% if item.member.role.code == "PM" %} +

기획자

+ {% elif item.member.role.name == "프론트엔드" %} +

프론트엔드

+ {% elif item.member.role.name == "백엔드" %} +

백엔드

+ {% endif %} +
+ {% endfor %} +
+
+
+ +
+
+ rule +

팀 규칙

+
+
+ {{ form.team_rules }} +
+
+ +
+
+ progress +
+

{{ project.title }}의 진척도

+
+
+
+

전체

+
+
+
+ {% if guide_progress %}{{ guide_progress.progress_percent }}{% else %}0{% endif %}% +
+
+ + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/templates/projects/kitup_detail.html b/templates/projects/kitup_detail.html new file mode 100644 index 0000000..1eb1a67 --- /dev/null +++ b/templates/projects/kitup_detail.html @@ -0,0 +1,186 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ project.title }}{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+ +
+
+ {% if project.project_image %} + {{ project.title }} + {% else %} + {{ project.title }} + {% endif %} + + + {{ project.get_like_count }} +
+
+ +
+

{{ project.title }}

+ {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+ +
+ +
+

팀 정보

+ +
    +
  • 팀명{{ team.name }}
  • +
  • 상태{{ project.get_status_display }}
  • +
  • 기간 + + {{ project.duration_weeks }}주 + {% if project.starts_at %} · {{ project.starts_at }}{% endif %} + {% if project.ends_at %} ~ {{ project.ends_at }}{% endif %} + +
  • + {% if project.region %} +
  • 지역{{ project.region }}
  • + {% endif %} +
+
+ +
+

팀원

+ +
+ {% for m in project.members %} +
+
{{ m.role.name }}
+
{{ m.user.username }}
+
+ {% empty %} +
팀원 정보가 없습니다.
+ {% endfor %} +
+
+ + {% if project.related_links %} +
+

관련 링크

+ + +
+ {% endif %} + + {% if project.team_rules %} +
+

팀 규칙

+
{{ project.team_rules }}
+
+ {% endif %} + +
+
+
+ +{% endblock %} + diff --git a/templates/projects/kitup_list.html b/templates/projects/kitup_list.html new file mode 100644 index 0000000..abc3df1 --- /dev/null +++ b/templates/projects/kitup_list.html @@ -0,0 +1,139 @@ +{% extends 'base.html' %} +{% load static %} +{% block header %} + +{% endblock %} +{% block content %} +
+
+ +

KITUP에 진행된 프로젝트

+ + {# 정렬 UI #} + {% with sort=request.GET.sort|default:"popular" %} + + {% endwith %} + + +
+
+ + + +{% endblock %} diff --git a/templates/reflections/_note_form.html b/templates/reflections/_note_form.html new file mode 100644 index 0000000..9e000d5 --- /dev/null +++ b/templates/reflections/_note_form.html @@ -0,0 +1,204 @@ +{% load reflections_extras %} +{% load static %} + +
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {# 상단 타이틀/태그/메뉴 라인 #} +
+ + +
+ {% if note and note.role %} + + {{ note.role }} + + {% endif %} + +
+ + + +
+
+
+ +
+ + {# 프로젝트/역할 선택(원하신 기능) #} +
+
+ +
+ + +
+
+ +
+ +
+ + +
+ 프로젝트 선택 시 역할이 자동 입력됩니다. (원하면 직접 변경 가능) +
+
+ + + + {# 질문 카드들 #} +
+ {% for q in guide.questions %} +
+
+
+ {{ q.order|default:forloop.counter }} + {{ q.title }} +
+ +
+ {# note 있거나 draft_key 있으면 업로드 버튼 활성 #} + + + +
+
+ +
+ +
+
+ {% endfor %} +
+ + {# 업로드된 이미지 리스트(note 있을 때만) #} + {% if note and note.assets.all %} +
+
업로드된 이미지
+
+ {% for asset in note.assets.all %} +
+ {{ asset.alt_text|default:'image' }} + +
+ {% endfor %} +
+
+ {% endif %} + + {# 하단 버튼 #} +
+ {% if note and note.id %} + 돌아가기 + {% else %} + 돌아가기 + {% endif %} + +
+ + + +
+ +{{ my_role_map|json_script:"roleMapJson" }} diff --git a/templates/reflections/note_create.html b/templates/reflections/note_create.html new file mode 100644 index 0000000..1bdf9a0 --- /dev/null +++ b/templates/reflections/note_create.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}회고 작성{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% include "reflections/_note_form.html" %} +
+ + +{% endblock %} diff --git a/templates/reflections/note_detail.html b/templates/reflections/note_detail.html new file mode 100644 index 0000000..cff24ef --- /dev/null +++ b/templates/reflections/note_detail.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% load static %} +{% load reflections_extras %} + +{% block title %}{{ note.title }} - 회고{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+
+
+
+

{{ note.title }}

+
+
+ 목록 + + + +
+ + + +
+
+
+
+ 작성일: {{ note.created_at|date:"Y-m-d H:i" }} · + 최종수정일: {{ note.updated_at|date:"Y-m-d H:i" }} +
+
+ +
+ {% for q in guide.questions %} +
+
+ {{ q.order|default:forloop.counter }} + {{ q.title }} +
+
+ {{ answers|get_item:q.id|md }} +
+
+ {% endfor %} +
+ + {# 마크다운 내보내기(접기) #} +
+ + 마크다운 내보내기 + +
+ + +
+
+ +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/reflections/note_list.html b/templates/reflections/note_list.html new file mode 100644 index 0000000..51a8e8f --- /dev/null +++ b/templates/reflections/note_list.html @@ -0,0 +1,166 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}회고{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+ + {# 상단 컨트롤 영역 #} +
+ +
+ + {# 리스트 영역 #} +
+ {% if notes %} + {% for note in notes %} + + {% endfor %} + {% else %} +
+

작성된 회고가 없습니다.

+ 첫 회고 작성하기 +
+ {% endif %} +
+ +
+ + +{% endblock %} diff --git a/templates/reflections/note_update.html b/templates/reflections/note_update.html new file mode 100644 index 0000000..d446667 --- /dev/null +++ b/templates/reflections/note_update.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}회고 수정{% endblock %} + +{% block header %} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% include "reflections/_note_form.html" %} +
+ + +{% endblock %} diff --git a/templates/teams/passion_test.html b/templates/teams/passion_test.html new file mode 100644 index 0000000..6db5d07 --- /dev/null +++ b/templates/teams/passion_test.html @@ -0,0 +1,332 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}KITUP - 열정 레벨 진단{% endblock %} + +{% block header %} + + +{% endblock %} + +{% block content %} +
+ + +
+
+

열정 레벨 판별 설문

+

+
+ 비슷한 상황과 몰입도의 팀원을 매칭하기 위한 설문입니다.
+ 본인의 상황에 가장 가까운 항목을 골라주세요!
+

+
+ +
+
+
+ +
+ 1/10 +

+
+ +
+
+
+ +
+ + + +
+
+
+ + +
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/teams/team.html b/templates/teams/team.html new file mode 100644 index 0000000..12e7974 --- /dev/null +++ b/templates/teams/team.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} {% load static %} {% block header %} + +{% endblock %} {% block content %} +{% if team_matched %} +
+

+ 팀 매칭이 완료 되었어요.
+ 내 프로젝트에서 업데이트 된 팀 정보를 확인해보세요. +

+
+ {% for member in team_members %} +
+
+ {% if member.user.profile_image %} + 프로필사진 + {% else %} + 프로필사진 + {% endif %} + {% if member.level == 1 %} + 레벨1 + {% elif member.level == 2 %} + 레벨2 + {% elif member.level == 3 %} + 레벨3 + {% elif member.level == 4 %} + 레벨4 + {% endif %} +
+

{{ member.user.nickname }}
+ Lv{{ member.level }}

+ {% if member.role.code == "PM" %} +

기획자

+ {% elif member.role.code == "FRONTEND" %} +

프론트엔드

+ {% elif member.role.code == "BACKEND" %} +

백엔드

+ {% endif %} +
+ {% endfor %} +
+

내 프로젝트로 이동

+
+ +{% else %} +
+

팀 매칭에 실패했어요.
+ 다음 팀 매칭을 기다려주세요.

+

메인화면으로 이동

+
+{% endif %} +{% endblock %} diff --git a/templates/teams/team_apply.html b/templates/teams/team_apply.html new file mode 100644 index 0000000..2b0612c --- /dev/null +++ b/templates/teams/team_apply.html @@ -0,0 +1,162 @@ +{% extends 'base.html' %} {% load static %} {% block header %} + +{% endblock %} {% block content %} +{% if season.status != 'MATCHING' %} +
+

+ 지금은 팀 매칭 기간이 아니예요.
+ 팀 매칭 기간이 되면 메일로 알려드릴게요. +

+
+ {% csrf_token %} + +
+
+ +{% elif user_obj.passion_level == None %} +
+

팀 매칭 모집 기간이 시작됐어요!

+

+ 6주의 기간동안 희망하는 스택으로 프로젝트를 진행하고, 실력을 쌓아보아요. +

+
+
+

WEB 기획

+ {% if user.is_authenticated %} + {% with pm_level=role_levels.PM %} + {% if pm_level == 1 %} + Level1 +

Lv1

+ + {% elif pm_level == 2 %} + Level2 +

Lv2

+ + {% elif pm_level == 3 %} + Level3 +

Lv3

+ + {% elif pm_level == 4 %} + Level4 +

Lv4

+ + {% else %} + nolevel +

아직 레벨 진단이 완료되지 않았어요!

+ + {% endif %} + {% endwith %} + {% else %} + nolevel +

로그인 후 레벨을 확인해보세요.

+ + {% endif %} +
+
+

WEB 프론트엔드

+ {% if user.is_authenticated %} + {% with fe_level=role_levels.FRONTEND %} + {% if fe_level == 1 %} + Level1 +

Lv1

+ + {% elif fe_level == 2 %} + Level2 +

Lv2

+ + {% elif fe_level == 3 %} + Level3 +

Lv3

+ + {% elif fe_level == 4 %} + Level4 +

Lv4

+ + {% else %} + nolevel +

아직 레벨 진단이 완료되지 않았어요!

+ + {% endif %} + {% endwith %} + {% else %} + nolevel +

로그인 후 레벨을 확인해보세요.

+ + {% endif %} +
+
+

WEB 백엔드

+ {% if user.is_authenticated %} + {% with be_level=role_levels.BACKEND %} + {% if be_level == 1 %} + Level1 +

Lv1

+ + {% elif be_level == 2 %} + Level2 +

Lv2

+ + {% elif be_level == 3 %} + Level3 +

Lv3

+ + {% elif be_level == 4 %} + Level4 +

Lv4

+ + {% else %} + nolevel +

아직 레벨 진단이 완료되지 않았어요!

+ + {% endif %} + {% endwith %} + {% else %} + nolevel +

로그인 후 레벨을 확인해보세요.

+ + {% endif %} +
+
+ +
+ +{% else %} +
+

+ 팀 매칭 신청이 완료되었어요.
+ 팀 매칭 결과가 발표되면 메일로 알려드릴게요. +

+
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+
+{% endif %} + +{% endblock %} + +