Feat: [#72] 깃허브 관련 기능 구현 완료

This commit is contained in:
sm4640
2025-07-16 00:14:04 +09:00
parent c2b4f75dcd
commit 873db8d894
7 changed files with 218 additions and 2 deletions

View File

@@ -66,6 +66,7 @@ INSTALLED_APPS = [
'codes', 'codes',
'notifications', 'notifications',
'nocodetools', 'nocodetools',
'homes',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -117,6 +118,22 @@ DATABASES = {
} }
} }
# cache
REDIS_PASSWORD = env("REDIS_PASSWORD")
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': f'redis://:{REDIS_PASSWORD}@redis:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SERIALIZER': 'django_redis.serializers.json.JSONSerializer',
},
"KEY_PREFIX": "colio",
"TIMEOUT": 60 * 60,
}
}
# aws s3 # aws s3
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID') AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY') AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY')
@@ -250,6 +267,9 @@ SESSION_COOKIE_SECURE = True
GOOGLE_CLIENT_ID = env('GOOGLE_CLIENT_ID') GOOGLE_CLIENT_ID = env('GOOGLE_CLIENT_ID')
GITHUB_CLIENT_ID = env('GITHUB_CLIENT_ID')
GITHUB_CLIENT_SECRET = env('GITHUB_CLIENT_SECRET')
##CORS ##CORS
# CSRF_TRUSTED_ORIGINS = [] # CSRF_TRUSTED_ORIGINS = []
# CORS_ALLOWED_ORIGINS = ["http://localhost:3000", "https://{프론트주소}.vercel.app/"] # CORS_ALLOWED_ORIGINS = ["http://localhost:3000", "https://{프론트주소}.vercel.app/"]

View File

@@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.conf import settings
from common.models.baseModels import BaseModel from common.models.baseModels import BaseModel
from common.models.choiceModels import GenderChoices, CertificateCodeUseType from common.models.choiceModels import GenderChoices, CertificateCodeUseType
from common.utils.codeManger import set_expire from common.utils.codeManger import set_expire
@@ -9,6 +10,9 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Permis
from common.models.choiceModels import NotificationType from common.models.choiceModels import NotificationType
from django.utils import timezone
from datetime import timedelta
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
def create_user(self, email, password, **kwargs): def create_user(self, email, password, **kwargs):
user = self.model(email = email, **kwargs) user = self.model(email = email, **kwargs)
@@ -72,3 +76,14 @@ class User(BaseModel, AbstractBaseUser, PermissionsMixin):
if self.banner_image: if self.banner_image:
self.banner_image.delete(save=False) self.banner_image.delete(save=False)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
class GithubToken(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='github_token', on_delete=models.CASCADE)
access_token = models.CharField(max_length=255, blank=True, null=True)
refresh_token = models.CharField(max_length=255, blank=True, null=True)
scope = models.CharField(max_length=255, blank=True)
token_type = models.CharField(max_length=32, default='bearer')
expires_at = models.DateTimeField(blank=True, null=True)
def is_expired(self) -> bool:
return self.expires_at and timezone.now() >= self.expires_at

11
users/permissions.py Normal file
View File

@@ -0,0 +1,11 @@
# permissions.py
from rest_framework.permissions import BasePermission
class IsGithubLinked(BasePermission):
def has_permission(self, request, view):
user = request.user
if token_obj := getattr(user, 'github_token', None):
if token := getattr(token_obj, 'access_token', None):
return True
return False

View File

@@ -74,3 +74,7 @@ class UserMemberInfoSerializer(serializers.ModelSerializer):
'custom_url', 'custom_url',
'job_and_interests' 'job_and_interests'
] ]
class GitHubCodeSerializer(serializers.Serializer):
code = serializers.CharField()

View File

@@ -1,3 +1,5 @@
import requests
from .models import * from .models import *
from projects.models import * from projects.models import *
from portfolios.models import * from portfolios.models import *
@@ -5,6 +7,8 @@ from portfolios.models import *
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from django.conf import settings
DUPLICATE_CHECK = { DUPLICATE_CHECK = {
'email': 'email', 'email': 'email',
@@ -12,6 +16,8 @@ DUPLICATE_CHECK = {
'phone': 'phone' 'phone': 'phone'
} }
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
class CheckUserFieldDuplicateService: class CheckUserFieldDuplicateService:
@staticmethod @staticmethod
def check_duplicate(field: str, value: str) -> bool: def check_duplicate(field: str, value: str) -> bool:
@@ -92,3 +98,35 @@ class UserToProjectService:
@staticmethod @staticmethod
def get_scrap_project(user: User): def get_scrap_project(user: User):
return user.scrapped_projects.filter(is_published=True) return user.scrapped_projects.filter(is_published=True)
class GithubTokenService:
@staticmethod
def exchange_code_to_access_token(code: str) -> dict:
try:
resp = requests.post(
GITHUB_TOKEN_URL,
headers={"Accept": "application/json"},
data = {
"client_id": settings.GITHUB_CLIENT_ID,
"client_secret": settings.GITHUB_CLIENT_SECRET,
"code": code,
},
timeout=10,
)
resp.raise_for_status()
return resp.json()
except requests.HTTPError as e:
raise Exception(
f"Github token exchange failed: {e.response.status_code} - {e.response.text}"
)
def github_token_save(data: dict, user: User):
return GithubToken.objects.update_or_create(
user=user,
defaults={
"access_token": data["access_token"],
"scope": data.get("scope", ""),
"token_type": data.get("token_type", "bearer"),
},
)

View File

@@ -1,7 +1,12 @@
from django.urls import path from django.urls import include, path
from .views import * from .views import *
from rest_framework.routers import DefaultRouter
router = DefaultRouter(trailing_slash=True)
router.register(r"action", GithubAPIViewSet, basename="github")
app_name = 'users' app_name = 'users'
@@ -17,4 +22,5 @@ urlpatterns = [
path('mypage/profile/<str:nickname>/', MyPageProfileAPIView.as_view()), path('mypage/profile/<str:nickname>/', MyPageProfileAPIView.as_view()),
path('mypage/works/<str:nickname>/', MyPageWorkListAPIView.as_view()), path('mypage/works/<str:nickname>/', MyPageWorkListAPIView.as_view()),
path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()), path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()),
path("github/", include(router.urls)),
] ]

View File

@@ -8,16 +8,20 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, Toke
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
from rest_framework import status from rest_framework import viewsets, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.decorators import action
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.db.models import Case, When, Value, IntegerField, Q from django.db.models import Case, When, Value, IntegerField, Q
from django.utils.functional import cached_property
from django.core.cache import cache
from .models import * from .models import *
from .serializers import * from .serializers import *
from .services import * from .services import *
from .permissions import *
from common.utils.fileManager import file_delete from common.utils.fileManager import file_delete
@@ -29,6 +33,10 @@ from django.db import transaction
from google.oauth2 import id_token from google.oauth2 import id_token
from google.auth.transport import requests from google.auth.transport import requests
from github import Github, GithubException
CACHE_TIMEOUT = 60 * 60
COMMIT_PAGE_SIZE = 20
class RefreshAPIView(APIView): class RefreshAPIView(APIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
@@ -107,7 +115,121 @@ class GoogleLoginAPIView(APIView):
status=status.HTTP_200_OK status=status.HTTP_200_OK
) )
class GithubAPIViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated, IsGithubLinked]
# 깃허브 연동 및 api 요청
def _get_github(self):
uid = self.request.user.id
cache_key = f"gh-token:{uid}"
token = cache.get(cache_key)
if token is None:
token = self.request.user.github_token.access_token
cache.set(cache_key, token, timeout=CACHE_TIMEOUT)
return Github(token)
@cached_property
def github(self):
return self._get_github()
@transaction.atomic
@action(detail=False, methods=["post"], url_path="connect", permission_classes=[IsAuthenticated])
def connect(self, request):
serializer = GithubCodeSerializer(data=request.data)
if serializer.is_valid():
try:
data = GithubTokenService.exchange_code_to_access_token(serializer.validated_data['code'])
except Exception as e:
return Response({"connected": False, "detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
GithubTokenService.github_token_save(data, request.user)
return Response({"connected": True}, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="personal-repos")
def personal_repos(self, request):
user = self.github.get_user()
data = [
{'full_name': r.full_name}
for r in user.get_repos()
]
return Response(data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="commits")
def commits(self, request):
repo_full_name = request.query_params.get("repo")
if not repo_full_name:
return Response({"is_query_param": False}, status=status.HTTP_400_BAD_REQUEST)
try:
page = int(request.query_params.get("page", 1))
except ValueError:
return Response({"is_page_int": False,"detail": "page and page_size must be integers"}, status=status.HTTP_400_BAD_REQUEST)
if page < 1:
return Response({"is_page_gte_1": False, "detail": "page and page_size must be >= 1"}, status=status.HTTP_400_BAD_REQUEST)
try:
repo = self.github.get_repo(repo_full_name)
commits = repo.get_commits(per_page=COMMIT_PAGE_SIZE).get_page(page - 1)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
data = [
{"sha": c.sha, "commit_message": c.commit.message}
for c in commits[:COMMIT_PAGE_SIZE]
]
return Response({
"page": page,
"page_size": COMMIT_PAGE_SIZE,
"results": data
}, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="commit-files")
def commit_files(self, request):
repo_full, sha = request.query_params.get("repo"), request.query_params.get("sha")
if not (repo_full and sha):
return Response({"is_query_param": False}, status=status.HTTP_400_BAD_REQUEST)
commit = self.github.get_repo(repo_full).get_commit(sha)
return Response([{"filename": f.filename, "status": f.status} for f in commit.files])
@action(detail=False, methods=["get"], url_path="file-content")
def file_content(self, request):
repo_full = request.query_params.get("repo")
filename = request.query_params.get("filename")
sha = request.query_params.get("sha")
if not (repo_full and filename and sha):
return Response({"is_query_param": False}, status=status.HTTP_400_BAD_REQUEST)
try:
file = self.github.get_repo(repo_full).get_contents(path=filename, ref=sha)
if file.encoding == "base64":
content = file.decoded_content.decode("utf-8", errors="replace")
else:
content = file.content
return Response({"path": file.path, "ref": sha, "content": content})
except GithubException as e:
return Response({"detail": str(e)}, status=e.status)
@action(detail=False, methods=["get"], url_path="organizations")
def organizations(self, request):
user = self.github.get_user()
orgs = user.get_orgs()
data = {"org_name": [org.name for org in orgs]}
return Response(data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="organization-repos")
def organization_repos(self, request):
org_name = request.query_params.get("org")
repos = self.github.get_organization(org_name).get_repos()
data = [
{'full_name': r.full_name}
for r in repos
]
return Response(data, status=status.HTTP_200_OK)
class JoinAPIView(APIView): class JoinAPIView(APIView):