× 
project Ti Manager2는 Ti 업무 의뢰, 업무 배정, 진행 상태 공유, 업체 별 통계 등 업무상 Ti 관련 전반에 쓰는 업무용 웹 사이트이다.
Ti Manager1은 당시 파이썬 2달 배우고 3달째에 바로 장고로 만들었던 툴이라 오류가 많았고, 6년째 데이터가 쌓이다 보니 사이트가 동작을 하지 못할 정도로 느려렸다.
이번에 로딩에 관련 DB 설계에 더욱 중점을 두고 일부 기능 변경을 하여 새로 만들기로 했다. 추가로 연도 별 데이터 엑셀 추출도 이번에 넣기로 했다.
프로젝트 기간: 06.05 ~
파이썬 장고에 기반을 두고 mariaDB로 진행 / 기존 ti1에서 DB 커스텀 이전(항목 일부 상이)
# 가상환경 D:\Python\python311\python.exe -m venv vTiManager2 # 장고 pip install django # MySQL DB pip install pymysql # 장고 CORS pip install django-cors-headers # iis 세팅용 pip install wfastcgi # html 접근 pip install beautifulsoup4 # 엑셀 다운로드 pip install openpyxl # 이미지 필드 사용을 위해서 pip install Pillow # 메일링 pip install pywin32 # 기본 프로젝트 django-admin startproject back . # 앱 python manage.py startapp org # 사원 관련 python manage.py startapp web python manage.py startapp setting python manage.py startapp utils python manage.py startapp report
import os import pymysql from pathlib import Path from django.contrib.messages import constants as messages pymysql.install_as_MySQLdb() BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = 'django-insecure-^t4f83^734b1jkn@gefq!=@u$n@lv)=t0u1ez+2=l$f%m%)s^k' DEBUG = True ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'corsheaders', # 장고 CORS 'org.apps.OrgConfig', 'web.apps.WebConfig', 'setting.apps.SettingConfig', 'utils.apps.UtilsConfig', ] AUTH_USER_MODEL = 'org.User' MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'back.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [os.path.join(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 = 'back.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'ti2', 'USER': 'root', #root 'PASSWORD': '0000', #password 'HOST': 'localhost', 'PORT': '3306', } } # 쉬운 비밀번호 사용을 위해 비밀번호 유효성 검사기 제거 AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': { 'min_length': 4, # 최소 길이를 4로 설정 } }, ] LANGUAGE_CODE = 'ko' TIME_ZONE = 'Asia/Seoul' USE_I18N = True USE_TZ = True DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' STATIC_URL = '/static/' STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MESSAGE_TAGS = { messages.DEBUG: 'debug', messages.INFO: 'info', messages.SUCCESS: 'success', messages.WARNING: 'warning', messages.ERROR: 'danger', } # 보안 설정 추가 SECURE_CONTENT_TYPE_NOSNIFF = True # 브라우저가 Content-Type 헤더를 무시하지 못하도록 설정 - 콘텐츠 스니핑(콘텐츠 유형 추측)으로 인한 공격을 방지 SECURE_BROWSER_XSS_FILTER = True # 브라우저의 XSS(크로스 사이트 스크립팅) 필터를 활성화 - 사용자가 입력한 데이터가 악의적으로 조작되어 XSS 공격에 악용되는 것을 방지 X_FRAME_OPTIONS = 'DENY' # 페이지가 iframe으로 렌더링되는 것을 방지 - 클릭재킹(clickjacking) 공격을 방지 SECURE_CROSS_ORIGIN_OPENER_POLICY = None # Django에서 기본으로 설정되는 Cross-Origin-Opener-Policy (COOP) 헤더를 비활성화 # 로그인 아웃 후 리다이렉션 경로 LOGIN_URL = '/login/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/login/'
from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static from web import views urlpatterns = [ path('admin/', admin.site.urls), path('', views.index, name='index'), path('', include('org.urls')), path('web/', include('web.urls')), path('setting/', include('setting.urls')), ] # 개발 환경에서 미디어 파일 제공 if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
from django import forms from django.db import models from django.urls import path from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from org.models import Department, Team, User, Member from org.views import member_update # OUTLOOK_AVAILABLE 변수를 전역에서 정의 try: import win32com.client OUTLOOK_AVAILABLE = True except ImportError: OUTLOOK_AVAILABLE = False @admin.register(Department) class AdminDepartment(admin.ModelAdmin): list_display = ['department_name'] list_display_links = ['department_name'] list_filter = ['department_name'] @admin.register(Team) class AdminTeam(admin.ModelAdmin): list_display = ['team_name'] list_display_links = ['team_name'] list_filter = ['team_name'] class CustomUserForm(forms.ModelForm): class Meta: model = User fields = '__all__' class CustomUserAdmin(BaseUserAdmin): form = CustomUserForm formfield_overrides = { models.DecimalField: {'widget': forms.NumberInput(attrs={'step': '0.01'})}, } # 관리자 페이지에 표시될 필드 설정 list_display = ['user_name', 'email', 'user_phone', 'user_department', 'user_team', 'user_position'] list_display_links = ['user_name', 'email'] list_filter = ['user_name', 'email', 'user_team', 'user_department'] search_fields = ['user_name', 'email', 'user_department', 'user_team'] ordering = ['user_name'] fieldsets = ( (None, {'fields': ('email', 'password')}), ('Personal info', {'fields': ('user_name', 'user_phone', 'user_img', 'user_department', 'user_team', 'user_position')}), ('Permissions', {'fields': ('is_staff', 'is_manager', 'is_superuser', 'groups', 'user_permissions')}), ('Important dates', {'fields': ('last_login',)}), ) add_fieldsets = ( (None, { 'classes': ('wide',), 'fields': ('email', 'password1', 'password2', 'user_name', 'user_phone', 'user_img', 'user_department', 'user_team', 'user_position', 'user_start')} ), ) # 커스텀 UserAdmin을 등록 admin.site.register(User, CustomUserAdmin) @admin.register(Member) class MemberAdmin(admin.ModelAdmin): change_list_template = "admin/member.html" list_display = ['member_name', 'member_email', 'member_team', 'member_department', 'member_position'] ordering = ['member_department', 'member_team', 'member_name'] def get_urls(self): urls = super().get_urls() custom_urls = [ path('member_update/', self.member_update, name='member_update'), ] return custom_urls + urls def member_update(self, request): return member_update(self, request)
from django import forms from django.forms.widgets import ClearableFileInput from django.contrib.auth import password_validation from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import PasswordChangeForm as DjangoPasswordChangeForm from django.core.exceptions import ValidationError from .models import User class LoginForm(AuthenticationForm): username = forms.EmailField( label="이메일", widget=forms.EmailInput(attrs={"autofocus": True, "value": ""}) # 기본값을 빈 값으로 설정 ) password = forms.CharField(label="비밀번호", widget=forms.PasswordInput) # 프로필 변경에 기존 취소를 이미지 삭제로 텍스트 변경 class CustomClearableFileInput(ClearableFileInput): clear_checkbox_label = '이미지 삭제만 원할 경우 체크 후 SAVE' # '취소'를 '이미지 삭제'로 변경 class ProfileUpdateForm(forms.ModelForm): class Meta: model = User fields = ['user_name', 'user_phone', 'user_department', 'user_team', 'user_position', 'user_img'] widgets = { 'user_img': CustomClearableFileInput, # 사용자 정의 ClearableFileInput 적용 } class PasswordChangeForm(forms.Form): old_password = forms.CharField(widget=forms.PasswordInput(), label="기존 비밀번호") new_password = forms.CharField(widget=forms.PasswordInput(), label="새 비밀번호") confirm_password = forms.CharField(widget=forms.PasswordInput(), label="비밀번호 확인") def __init__(self, *args, **kwargs): self.user = kwargs.pop('user', None) # 현재 사용자를 전달 받음 super().__init__(*args, **kwargs) def clean_old_password(self): old_password = self.cleaned_data.get('old_password') if not self.user.check_password(old_password): raise forms.ValidationError("기존 비밀번호가 일치하지 않습니다.") return old_password def clean(self): cleaned_data = super().clean() new_password = cleaned_data.get('new_password') confirm_password = cleaned_data.get('confirm_password') if new_password != confirm_password: raise forms.ValidationError("새 비밀번호가 일치하지 않습니다.") # Django의 기본 비밀번호 유효성 검사를 사용 try: password_validation.validate_password(new_password, self.user) except ValidationError as e: self.add_error('new_password', e) return cleaned_data
from django.db import models from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin class Department(models.Model): department_code = models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='본부 고유코드') department_name = models.CharField(verbose_name='본부 이름 ', max_length=50) def __str__(self): return self.department_name class Team(models.Model): team_code = models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='팀 고유코드') team_name = models.CharField(verbose_name='팀 이름 ', max_length=50) department = models.ForeignKey(Department, verbose_name='부서 선택', on_delete=models.CASCADE, null=True, blank=True) def __str__(self): return self.team_name class UserManager(BaseUserManager): # 유저 생성 메서드 def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError('이메일을 아이디로 쓰고 있습니다. 이메일은 필수입니다.') email = self.normalize_email(email) # 이메일 형식을 표준화 (대소문자 구분 없이) user = self.model(email=email, **extra_fields) # 유저 생성 user.set_password(password) # 비밀번호 설정 user.save(using=self._db) # 데이터베이스에 저장 return user # 관리자(슈퍼유저) 생성 메서드 def create_superuser(self, email, password=None, **extra_fields): extra_fields.setdefault('is_staff', True) extra_fields.setdefault('is_superuser', True) extra_fields.setdefault('is_manager', True) return self.create_user(email, password, **extra_fields) class User(AbstractBaseUser, PermissionsMixin): POSITION_CHOICES = [ ('intern', '인턴'), ('staff', '사원'), ('associate', '주임'), ('assistant', '대리'), ('manager', '과장'), ('chief', '차장'), ('director', '부장'), ('leader', '팀장'), ('executive', '이사'), ('CEO', '대표이사') ] user_code = models.AutoField(primary_key=True) # 자동 증가하는 고유 코드 email = models.EmailField(unique=True) # 로그인에 사용되는 이메일 (중복 불가) password = models.CharField(max_length=128) # 기본 비밀번호 필드 (AbstractBaseUser에서 제공) user_name = models.CharField(max_length=100) # 사용자 이름 user_phone = models.CharField(max_length=15, null=True, blank=True) # 전화번호 필드 user_img = models.ImageField(upload_to='profiles/', null=True, blank=True) # 프로필 이미지 user_department = models.ForeignKey(Department, on_delete=models.CASCADE, null=True, blank=True) # 소속 본부 정보 user_team = models.ForeignKey(Team, on_delete=models.CASCADE, null=True, blank=True) user_position = models.CharField(max_length=50, choices=POSITION_CHOICES) # 직급 정보 (인턴, 사원 등) is_manager = models.BooleanField(default=False) # 관리자 여부 is_staff = models.BooleanField(default=False) # 관리자 페이지 접근 여부 is_working = models.BooleanField(default=True) # 근무 여부 create_time = models.DateTimeField(auto_now_add=True) # 계정 생성 시간 update_time = models.DateTimeField(auto_now=True) # 업데이트 시간 objects = UserManager() # 커스텀 매니저 설정 # 이메일을 로그인 아이디로 사용하기 위한 설정 USERNAME_FIELD = 'email' # 로그인에 사용할 필드를 이메일로 설정 def __str__(self): return self.user_name # 객체 출력 시 유저 이름 반환 class Member(models.Model): POSITION_CHOICES = [ ('etc', '고객사'), ('intern', '인턴'), ('staff', '사원'), ('associate', '주임'), ('assistant', '대리'), ('manager', '과장'), ('chief', '차장'), ('director', '부장'), ('leader', '팀장'), ('executive', '이사'), ('CEO', '대표이사') ] member_code = models.AutoField(primary_key=True) # 자동 증가하는 고유 코드 member_email = models.EmailField(unique=False) # 유니크하지 않은 이메일 필드 member_name = models.CharField(max_length=100) # 이름 member_phone = models.CharField(max_length=15, null=True, blank=True) # 전화번호 member_img = models.ImageField(upload_to='profiles/', null=True, blank=True) # 프로필 이미지 member_department = models.ForeignKey(Department, on_delete=models.CASCADE, null=True, blank=True) # 소속 본부 member_team = models.ForeignKey(Team, on_delete=models.CASCADE, null=True, blank=True) # 소속 팀 member_position = models.CharField(max_length=50, choices=POSITION_CHOICES) # 직급 정보 member_start = models.DateField(null=True, blank=True) # 입사일 is_working = models.BooleanField(default=True) # 근무 여부 create_time = models.DateTimeField(auto_now_add=True) # 계정 생성 시간 update_time = models.DateTimeField(auto_now=True) # 업데이트 시간 def __str__(self): return f"{self.member_team} - {self.member_name}" def save(self, *args, **kwargs): # 소속팀이 비어있는 경우 "한샘글로벌" 팀을 기본값으로 설정 if self.member_team is None: hanssem_global_team, created = Team.objects.get_or_create(team_name="한샘글로벌") self.member_team = hanssem_global_team super().save(*args, **kwargs)
from django.urls import path, include from django.contrib.auth import views as auth_views from . import views from org.admin import MemberAdmin # app_name = 'org' # org만 네임스페이스 없이 사용 urlpatterns = [ path('login/', views.login_view, name='login'), path('logout/', auth_views.LogoutView.as_view(next_page='/login/'), name='logout'), # 로그아웃 URL 추가 path('mypage/', views.mypage, name='mypage'), path('profile/', views.profile, name='profile'), path('password/', views.password, name='password'), ]
import os import csv from datetime import datetime from django.contrib import messages from django.contrib.auth import update_session_auth_hash, authenticate, login from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.decorators import login_required from django.shortcuts import render, redirect from org.models import Department, Team, Member from org.forms import LoginForm, PasswordChangeForm, ProfileUpdateForm def login_view(request): if request.method == 'POST': form = LoginForm(request, data=request.POST) if form.is_valid(): email = form.cleaned_data.get('username') or '' password = form.cleaned_data.get('password') or '' # 이메일을 사용해 사용자 인증 user = authenticate(request, username=email, password=password) if user is not None: login(request, user) next_url = request.GET.get('next', '/mypage/') return redirect(next_url) else: # 로그인 실패 시 메시지 추가 messages.error(request, '로그인 실패: 이메일 또는 비밀번호를 확인하세요.') else: # 폼 유효성 검사 실패 시 수동으로 오류 처리 for field, errors in form.errors.items(): for error in errors: messages.error(request, error) # 각 필드의 오류를 메시지로 전달 else: form = LoginForm(initial={'username': ''}) return render(request, 'login.html', {'form': form}) @login_required def mypage(request): user = request.user return render(request, 'mypage.html', { 'user': user, }) @login_required def profile(request): user = request.user if request.method == 'POST': form = ProfileUpdateForm(request.POST, request.FILES, instance=user) # instance로 기존 데이터 연결 if form.is_valid(): form.save() messages.success(request, '프로필이 성공적으로 업데이트되었습니다.') return redirect('mypage') # 성공 시 마이페이지로 리디렉션 else: messages.error(request, '프로필 업데이트에 실패했습니다.') else: form = ProfileUpdateForm(instance=user) # 기존 데이터로 폼을 미리 채움 return render(request, 'profile.html', {'form': form, 'user': user}) @login_required def password(request): if request.method == 'POST': form = PasswordChangeForm(user=request.user, data=request.POST) if form.is_valid(): user = request.user user.set_password(form.cleaned_data['new_password']) # 비밀번호 변경 user.save() messages.success(request, "비밀번호가 성공적으로 변경되었습니다.") update_session_auth_hash(request, user) # 세션 갱신 return redirect('mypage') # 성공 시 마이페이지로 리디렉션 else: messages.error(request, "비밀번호 변경에 실패했습니다. 다시 시도해주세요.") return render(request, 'password.html', {'form': form}) else: form = PasswordChangeForm(user=request.user) return render(request, 'password.html', {'form': form}) # 부서 정보를 계산하는 함수 (공백 제거 추가) def get_department(team_name): team_name_cleaned = team_name.replace(" ", "") # 팀 이름의 모든 공백을 제거 department_mapping = { '경영지원팀': None, '매뉴얼개발팀': '콘텐츠기획본부', '콘텐츠개발팀': '콘텐츠기획본부', '시스템개발팀': '콘텐츠기획본부', '현지화개발팀': '랭귀지서비스본부', '랭귀지서비스팀': '랭귀지서비스본부', # 공백이 없는 팀 이름 'CMD팀': 'CMD본부', '콘텐츠기획본부': '콘텐츠기획본부', '랭귀지서비스본부': '랭귀지서비스본부', 'CMD본부': 'CMD본부', '세일즈&마케팅본부': '세일즈&마케팅본부', '미국지사': '미국지사', '베트남지사': '베트남지사' } return department_mapping.get(team_name_cleaned, None) # 직급 정보를 매칭하는 함수 (공백 제거 추가) def get_position(raw_position): if raw_position: position_cleaned = raw_position.split('(')[0].strip().replace(" ", "") # 괄호 앞부분과 공백 제거 else: position_cleaned = 'staff' POSITION_CHOICES = [ ('intern', '인턴'), ('staff', '사원'), ('associate', '주임'), ('assistant', '대리'), ('manager', '과장'), ('chief', '차장'), ('director', '부장'), ('leader', '팀장'), ('executive', '이사'), ('CEO', '대표이사') ] for key, value in POSITION_CHOICES: if position_cleaned in value.replace(" ", ""): # 직급도 공백을 제거하고 비교 return key return None # 매칭되지 않을 경우 기본값 # CSV 파일을 이용해 Member 데이터베이스를 업데이트하는 함수 def member_update(self, request): csv_file_path = os.path.join('static', 'file', 'outlook_out.csv') if not os.path.exists(csv_file_path): messages.error(request, "CSV 파일이 존재하지 않습니다.") return redirect('admin:org_member_changelist') with open(csv_file_path, mode='r', encoding='utf-8-sig') as file: reader = csv.DictReader(file) for row in reader: # 이메일이 존재하는지 확인 member_email = row.get('email') if not member_email: continue # 이메일이 없으면 처리하지 않음 # 이름에서 + 또는 * 표시 앞의 값만 사용 member_name = row.get('name').split('+')[0].split('*')[0].strip() # 직급 정보에서 괄호 앞 부분만 사용, 기본값 'staff' 설정 member_position = get_position(row.get('position', '')) or 'staff' # 부서에 따른 본부 계산 member_team_name = row.get('department', '') member_department_name = get_department(member_team_name) # 부서 이름으로 Department 인스턴스를 조회하거나 생성 member_department = None if member_department_name: member_department, created = Department.objects.get_or_create(department_name=member_department_name) # 팀 이름으로 Team 인스턴스를 조회하거나 생성 member_team = None if member_team_name: member_team, created = Team.objects.get_or_create(team_name=member_team_name) # 입사 날짜 추출 및 포맷 변환 (입사일이 없으면 None으로 처리) join_date = row.get('join_date') member_start = None if join_date and join_date != "입사일 없음": try: # join_date가 YYYY.MM.DD 형식일 경우 YYYY-MM-DD로 변환 member_start = datetime.strptime(join_date, '%Y.%m.%d').date() except ValueError: member_start = None # 형식이 맞지 않으면 None으로 처리 # 핸드폰 번호 member_phone = row.get('phone') # 기존 이메일 값이 있는지 확인 후 업데이트 또는 새로 생성 member, created = Member.objects.update_or_create( member_email=member_email, defaults={ 'member_name': member_name, 'member_phone': member_phone, 'member_department': member_department, # Department 인스턴스 할당 'member_team': member_team, # Team 인스턴스 할당 'member_position': member_position, # 기본값 할당 'member_start': member_start, # 변환된 날짜 값 할당 } ) if created: messages.success(request, f"{member_name} 님이 새로 추가되었습니다.") else: messages.success(request, f"{member_name} 님의 정보가 업데이트되었습니다.") # 파일 처리 후 리디렉션 return redirect('admin:org_member_changelist')
from django.urls import path, re_path from . import views urlpatterns = [ path('', views.index, name='index'), ]
from django.shortcuts import render def index(request): return render(request, 'index.html')
from django.urls import path from . import views urlpatterns = [ path('', views.setting_view, name='setting'), ]
from django.shortcuts import render def setting_view(request): return render(request, 'setting.html')
from django.urls import path from . import views urlpatterns = []
마리아 DB 설치 방법: [python/setting] 파이썬 – Django 설치 + 셋팅
create database ti2; # 데이터베이스 생성 use ti2; # ti2 DB 사용 show databases; # 데이터베이스 생성 확인
마이그레이션 하기 전 필수 주의 사항! 각, 앱마다 migrations/init.py – 파일들이 있어야 makemigrations 시 앱 인식이 제대로 된다. 이때 init.py는 내부 빈 파일이면 된다.
(보통은 자동 생성되는데 내가 일부 세팅 파일을 그냥 복사해서 쓰다 보니 생긴 문제)
python manage.py makemigrations python manage.py migrate python manage.py createsuperuser