├── .github └── workflows │ └── django.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── ad ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_promotion_image.py │ ├── 0003_promotion_click_times.py │ ├── 0004_alter_promotion_touchpoint_delete_touchpoint.py │ ├── 0005_promotion_touchpoint_one_online.py │ ├── 0006_promotion_external_image_alter_promotion_image.py │ └── __init__.py ├── models.py ├── repository.py ├── serializers.py ├── storage.py ├── tests.py ├── urls.py └── views.py ├── jcourse ├── __init__.py ├── asgi.py ├── paginations.py ├── renderers.py ├── settings.py ├── throttles.py ├── urls.py └── wsgi.py ├── jcourse_api ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── check_duplicate.py │ │ ├── export_courses.py │ │ ├── import.py │ │ ├── merge_course.py │ │ ├── merge_user.py │ │ ├── rename.py │ │ └── update_semester.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_report_reply.py │ ├── 0003_auto_20210913_2317.py │ ├── 0004_auto_20210915_1849.py │ ├── 0005_alter_action_options.py │ ├── 0006_alter_teacher_title.py │ ├── 0007_auto_20210922_2212.py │ ├── 0008_alter_review_rating.py │ ├── 0009_alter_review_comment.py │ ├── 0010_auto_20211207_1456.py │ ├── 0011_auto_20211215_1803.py │ ├── 0012_alter_action_options.py │ ├── 0013_auto_20211216_1702.py │ ├── 0014_userpoint.py │ ├── 0015_alter_userpoint_description.py │ ├── 0016_alter_enrollcourse_user_alter_userpoint_user.py │ ├── 0017_review_modified.py │ ├── 0018_course_last_semester_teacher_last_semester.py │ ├── 0019_alter_course_review_avg_alter_review_approve_count_and_more.py │ ├── 0020_alter_review_comment.py │ ├── 0021_semester_available.py │ ├── 0022_update_point_rule.py │ ├── 0023_notice_url.py │ ├── 0024_alter_review_ordered_rule_.py │ ├── 0025_remove_course_category_course_categories.py │ ├── 0026_remove_category_count_remove_department_count.py │ ├── 0027_alter_course_categories.py │ ├── 0028_alter_review_comment.py │ ├── 0029_reviewrevision.py │ ├── 0030_alter_action_options_action_modified.py │ ├── 0031_rename_notice_announcement_and_more.py │ ├── 0032_rename_action_reviewreaction.py │ ├── 0033_notification.py │ ├── 0034_alter_reviewreaction_reaction.py │ ├── 0035_enrollcourse_created.py │ ├── 0036_coursenotificationlevel.py │ ├── 0037_alter_announcement_options_and_more.py │ ├── 0038_formercode_unique_record.py │ ├── 0039_review_search_vector.py │ ├── 0040_review_jcourse_api_search__43d524_gin.py │ ├── 0041_alter_reviewrevision_review.py │ ├── 0042_no_point_by_review.py │ ├── 0043_alter_reviewrevision_review.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── base.py │ ├── course.py │ ├── course_notification_level.py │ ├── notification.py │ ├── review.py │ ├── site.py │ └── user.py ├── permissions.py ├── repository │ └── __init__.py ├── serializers │ ├── __init__.py │ ├── base.py │ ├── course.py │ └── review.py ├── signals.py ├── tasks.py ├── tests │ ├── __init__.py │ ├── test_course.py │ ├── test_course_notification_level.py │ ├── test_misc.py │ ├── test_notification.py │ ├── test_review.py │ ├── test_user.py │ └── test_utils.py ├── urls.py ├── utils │ ├── __init__.py │ ├── duplicate.py │ ├── email.py │ ├── export.py │ ├── merge_course.py │ ├── merge_user.py │ ├── point.py │ ├── rename.py │ └── spam.py └── views │ ├── __init__.py │ ├── base.py │ ├── common.py │ ├── course.py │ ├── enroll.py │ ├── notification.py │ ├── review.py │ ├── site.py │ ├── upload.py │ └── user.py ├── manage.py ├── oauth ├── __init__.py ├── admin.py ├── apps.py ├── middlewares.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_userprofile_lowercase.py │ ├── 0003_userprofile_suspended_till.py │ ├── 0004_userprofile_last_seen_at.py │ └── __init__.py ├── models.py ├── tasks.py ├── tests.py ├── urls.py ├── utils.py └── views.py ├── requirements.txt ├── scripts ├── convert_yjs_json2csv.py ├── download_from_yjs.py ├── former_code.csv ├── import_csv.py ├── import_from_wj.py └── import_from_yjs.py └── utils ├── __init__.py ├── common.py ├── course_data_clean.py ├── cut_word.py └── mail.py /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: [ "3.12", "3.13" ] 17 | 18 | services: 19 | redis: 20 | image: redis 21 | ports: 22 | - 6379:6379 23 | postgres: 24 | image: postgres 25 | env: 26 | POSTGRES_PASSWORD: jcourse 27 | POSTGRES_DB: jcourse 28 | POSTGRES_USER: jcourse 29 | ports: 30 | - 5432:5432 31 | options: >- 32 | --health-cmd pg_isready 33 | --health-interval 10s 34 | --health-timeout 5s 35 | --health-retries 5 36 | env: 37 | POSTGRES_PASSWORD: jcourse 38 | POSTGRES_HOST: localhost 39 | REDIS_HOST: localhost 40 | steps: 41 | - uses: actions/checkout@v3 42 | - name: Set up Python ${{ matrix.python-version }} 43 | uses: actions/setup-python@v4 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | cache: 'pip' 47 | - name: Install Dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install -r requirements.txt 51 | - name: Migrate Database 52 | run: | 53 | python manage.py migrate 54 | - name: Run Tests 55 | run: | 56 | python manage.py test 57 | 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /data/ 2 | /static/ 3 | /.idea/ 4 | /.vscode/ 5 | __pycache__/ 6 | db.sqlite3 7 | .coverage 8 | .env 9 | /upload/ 10 | /venv/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim 2 | WORKDIR /django 3 | 4 | COPY requirements.txt requirements.txt 5 | RUN pip install -r requirements.txt -i https://mirror.sjtu.edu.cn/pypi/web/simple 6 | COPY . . 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Du Jiajun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jCourse_api: jCourse 的后端 2 | 本项目需要与 [jCourse](https://github.com/dujiajun/jcourse) 前端配合使用。 3 | 4 | ## 开始使用 5 | 6 | 1. 安装Python, Memcached 和 PostgreSQL 7 | 2. 安装依赖 8 | ```shell 9 | pip install -r requirements.txt 10 | ``` 11 | 3. 配置环境变量 12 | ```shell 13 | export POSTGRE_PASSWORD= 14 | export POSTGRE_HOST= 15 | export JACCOUNT_CLIENT_ID= 16 | export JACCOUNT_CLIENT_SECRET= 17 | ``` 18 | 4. 初始化数据库 19 | ```shell 20 | python manage.py migrate 21 | python manage.py createsuperuser 22 | ``` 23 | 5. 运行单元测试 24 | ```shell 25 | python manage.py test 26 | ``` 27 | 7. 运行服务器(仅用于开发测试) 28 | ```shell 29 | python manage.py runserver 30 | ``` 31 | 32 | ## 使用docker compose 33 | 参考 `docker-compose.yml` 如下 34 | ```yaml 35 | version: '3' 36 | services: 37 | backend: 38 | build: ./django # 替换为 jcourse_api 实际文件夹 39 | image: jcourse_api:1.0 40 | command: gunicorn jcourse.wsgi --bind 0.0.0.0:8000 41 | ports: 42 | - 8000:8000 43 | environment: 44 | HASH_SALT: 45 | SECRET_KEY: 46 | POSTGRES_PASSWORD: jcourse 47 | POSTGRES_HOST: db 48 | REDIS_HOST: cache 49 | JACCOUNT_CLIENT_ID: 50 | JACCOUNT_CLIENT_SECRET: 51 | EMAIL_HOST_USER: 52 | EMAIL_HOST_PASSWORD: 53 | LOGGING_FILE: ./data/django.log 54 | volumes: 55 | - ./static:/django/static 56 | - ./django-data:/django/data 57 | depends_on: 58 | - db 59 | - cache 60 | restart: always 61 | db: 62 | image: postgres:13 63 | volumes: 64 | - ./pgdata:/var/lib/postgresql/data 65 | environment: 66 | POSTGRES_DB: jcourse 67 | POSTGRES_USER: jcourse 68 | POSTGRES_PASSWORD: jcourse 69 | restart: always 70 | cache: 71 | image: redis:latest 72 | restart: always 73 | ``` 74 | -------------------------------------------------------------------------------- /ad/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/ad/__init__.py -------------------------------------------------------------------------------- /ad/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from ad.models import Promotion 4 | 5 | 6 | @admin.register(Promotion) 7 | class PromotionAdmin(admin.ModelAdmin): 8 | list_display = ('touchpoint', 'available', 'description', 'created_at', 'click_times') 9 | readonly_fields = ('click_times',) 10 | -------------------------------------------------------------------------------- /ad/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AdConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'ad' 7 | verbose_name = '营销' 8 | -------------------------------------------------------------------------------- /ad/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-06 13:40 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Touchpoint', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=64, unique=True, verbose_name='名称')), 21 | ], 22 | options={ 23 | 'verbose_name': '触点', 24 | 'verbose_name_plural': '触点', 25 | }, 26 | ), 27 | migrations.CreateModel( 28 | name='Promotion', 29 | fields=[ 30 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('image', models.ImageField(blank=True, null=True, upload_to='', verbose_name='图片地址')), 32 | ('text', models.TextField(blank=True, null=True, verbose_name='展示文字')), 33 | ('jump_link', models.URLField(blank=True, null=True, verbose_name='跳转链接')), 34 | ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间')), 35 | ('available', models.BooleanField(db_index=True, default=False, verbose_name='启用')), 36 | ('description', models.TextField(blank=True, null=True, verbose_name='描述')), 37 | ('touchpoint', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='ad.touchpoint', verbose_name='触点')), 38 | ], 39 | options={ 40 | 'verbose_name': '推广内容', 41 | 'verbose_name_plural': '推广内容', 42 | }, 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /ad/migrations/0002_alter_promotion_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-10 15:33 2 | 3 | import ad.storage 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('ad', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='promotion', 16 | name='image', 17 | field=models.ImageField(blank=True, null=True, storage=ad.storage.QiniuStorage(child_name='upload'), upload_to='', verbose_name='图片地址'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /ad/migrations/0003_promotion_click_times.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-11 11:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('ad', '0002_alter_promotion_image'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='promotion', 15 | name='click_times', 16 | field=models.IntegerField(default=0, verbose_name='点击次数'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /ad/migrations/0004_alter_promotion_touchpoint_delete_touchpoint.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-12 06:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('ad', '0003_promotion_click_times'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='promotion', 15 | name='touchpoint', 16 | field=models.IntegerField(blank=True, choices=[(1, '更多课程下方')], db_index=True, null=True, verbose_name='触点'), 17 | ), 18 | migrations.DeleteModel( 19 | name='Touchpoint', 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /ad/migrations/0005_promotion_touchpoint_one_online.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-12 06:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('ad', '0004_alter_promotion_touchpoint_delete_touchpoint'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name='promotion', 15 | constraint=models.UniqueConstraint(condition=models.Q(('available', True)), fields=('touchpoint',), name='touchpoint_one_online'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /ad/migrations/0006_promotion_external_image_alter_promotion_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-12 14:35 2 | 3 | import ad.storage 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('ad', '0005_promotion_touchpoint_one_online'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='promotion', 16 | name='external_image', 17 | field=models.URLField(blank=True, null=True, verbose_name='外部图片地址'), 18 | ), 19 | migrations.AlterField( 20 | model_name='promotion', 21 | name='image', 22 | field=models.ImageField(blank=True, null=True, storage=ad.storage.QiniuStorage(child_name='upload'), upload_to='', verbose_name='内部图片'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /ad/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/ad/migrations/__init__.py -------------------------------------------------------------------------------- /ad/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import UniqueConstraint, Q 3 | from django.utils import timezone 4 | 5 | from ad.storage import QiniuStorage 6 | 7 | 8 | class Promotion(models.Model): 9 | class TouchPointType(models.IntegerChoices): 10 | BELOW_RELATED_COURSE = 1, '更多课程下方' 11 | 12 | class Meta: 13 | verbose_name = '推广内容' 14 | verbose_name_plural = verbose_name 15 | constraints = [ 16 | UniqueConstraint(fields=["touchpoint"], condition=Q(available=True), name="touchpoint_one_online")] 17 | 18 | touchpoint = models.IntegerField(choices=TouchPointType.choices, verbose_name='触点', 19 | db_index=True, null=True, blank=True) 20 | image = models.ImageField(verbose_name='内部图片', null=True, blank=True, storage=QiniuStorage(child_name='upload')) 21 | external_image = models.URLField(verbose_name='外部图片地址', null=True, blank=True) 22 | text = models.TextField(verbose_name='展示文字', null=True, blank=True) 23 | jump_link = models.URLField(verbose_name='跳转链接', null=True, blank=True) 24 | created_at = models.DateTimeField(verbose_name='创建时间', default=timezone.now, db_index=True) 25 | available = models.BooleanField(verbose_name='启用', default=False, db_index=True) 26 | description = models.TextField(verbose_name='描述', null=True, blank=True) 27 | click_times = models.IntegerField(verbose_name='点击次数', default=0, null=False, blank=False) 28 | -------------------------------------------------------------------------------- /ad/repository.py: -------------------------------------------------------------------------------- 1 | from ad.models import Promotion 2 | 3 | 4 | def get_promotions(): 5 | return Promotion.objects.filter(available=True) 6 | -------------------------------------------------------------------------------- /ad/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ad.models import Promotion 4 | 5 | 6 | class PromotionSerializer(serializers.ModelSerializer): 7 | image = serializers.SerializerMethodField() 8 | 9 | class Meta: 10 | model = Promotion 11 | fields = ('id', 'touchpoint', 'image', 'text', 'jump_link') 12 | 13 | def get_image(self, obj: Promotion): 14 | if obj.external_image is not None and obj.external_image != "": 15 | return obj.external_image 16 | return obj.image.url 17 | -------------------------------------------------------------------------------- /ad/storage.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import Storage 2 | from django.utils.deconstruct import deconstructible 3 | from qiniu import Auth, put_data, BucketManager 4 | 5 | from jcourse import settings 6 | 7 | 8 | @deconstructible 9 | class QiniuStorage(Storage): 10 | 11 | def __init__(self, child_name): 12 | super().__init__() 13 | self.access_key = settings.QINIU_ACCESS_KEY 14 | self.secret_key = settings.QINIU_SECRET_KEY 15 | # 要上传的空间 16 | self.bucket_name = settings.QINIU_BUCKET_NAME 17 | self.base_url = settings.QINIU_BASE_URL 18 | # 构建鉴权对象 19 | self.auth = Auth(self.access_key, self.secret_key) 20 | self.child_name = child_name 21 | 22 | def _open(self, name, mode="rb"): 23 | pass 24 | 25 | def _save(self, name, content): 26 | token = self.auth.upload_token(self.bucket_name) 27 | file_data = content.file 28 | 29 | ret, info = put_data(token, self.new_name(name, self.child_name), 30 | file_data if isinstance(file_data, bytes) else file_data.read()) 31 | 32 | if info.status_code == 200: 33 | return f"{self.base_url}/{ret.get('key')}" 34 | else: 35 | raise Exception("Upload Qiniu Error") 36 | 37 | def exists(self, name): 38 | return False 39 | 40 | def url(self, name): 41 | return self.auth.private_download_url(name) 42 | 43 | def delete(self, name): 44 | bucket = BucketManager(self.auth) 45 | ret, info = bucket.delete(self.bucket_name, name) 46 | if ret == {} and info.status_code == 200: 47 | return True 48 | else: 49 | raise Exception(f"Delete {name} Error") 50 | 51 | @staticmethod 52 | def new_name(name, child_name): 53 | return f"{child_name}/{name}" 54 | -------------------------------------------------------------------------------- /ad/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | from rest_framework.test import APIClient 4 | 5 | from ad.models import Promotion 6 | 7 | 8 | class PromotionTest(TestCase): 9 | 10 | def setUp(self) -> None: 11 | self.client = APIClient() 12 | self.user = User.objects.create_user(username='test') 13 | self.promotion = Promotion.objects.create() 14 | self.client.force_login(self.user) 15 | 16 | def test_click(self): 17 | resp = self.client.post(f'/api/promotion/{self.promotion.id}/click/') 18 | self.assertEqual(resp.status_code, 200) 19 | self.promotion.refresh_from_db() 20 | self.assertEqual(self.promotion.click_times, 1) 21 | -------------------------------------------------------------------------------- /ad/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework.routers import DefaultRouter 3 | 4 | from ad.views import PromotionViewSet 5 | 6 | router = DefaultRouter() 7 | router.register('', PromotionViewSet, basename='promotion') 8 | 9 | urlpatterns = [ 10 | path('', include(router.urls)), 11 | ] 12 | -------------------------------------------------------------------------------- /ad/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import F 2 | from rest_framework import viewsets 3 | from rest_framework.decorators import action 4 | from rest_framework.response import Response 5 | 6 | from ad.models import Promotion 7 | from ad.repository import get_promotions 8 | from ad.serializers import PromotionSerializer 9 | 10 | 11 | # Create your views here. 12 | class PromotionViewSet(viewsets.ReadOnlyModelViewSet): 13 | queryset = get_promotions() 14 | serializer_class = PromotionSerializer 15 | pagination_class = None 16 | 17 | @action(detail=True, methods=['POST']) 18 | def click(self, request, pk=None): 19 | promotion = Promotion.objects.get(pk=pk) 20 | promotion.click_times = F('click_times') + 1 21 | promotion.save(update_fields=['click_times']) 22 | data = PromotionSerializer(many=False).data 23 | return Response(data) 24 | -------------------------------------------------------------------------------- /jcourse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/jcourse/__init__.py -------------------------------------------------------------------------------- /jcourse/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for jcourse project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'jcourse.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /jcourse/paginations.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination 2 | 3 | 4 | class GlobalPageNumberPagination(PageNumberPagination): 5 | max_page_size = 100 6 | page_size_query_param = 'size' 7 | -------------------------------------------------------------------------------- /jcourse/renderers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.renderers import BrowsableAPIRenderer 2 | 3 | 4 | class BrowsableAPIRendererWithoutForms(BrowsableAPIRenderer): 5 | """Renders the browsable api, but excludes the forms.""" 6 | 7 | def get_context(self, *args, **kwargs): 8 | ctx = super().get_context(*args, **kwargs) 9 | ctx['display_edit_forms'] = False 10 | return ctx 11 | 12 | def show_form_for_method(self, view, method, request, obj): 13 | """We never want to do this! So just return False.""" 14 | return False 15 | 16 | def get_rendered_html_form(self, data, view, method, request): 17 | """Why render _any_ forms at all. This method should return 18 | rendered HTML, so let's simply return an empty string. 19 | """ 20 | return "" 21 | -------------------------------------------------------------------------------- /jcourse/throttles.py: -------------------------------------------------------------------------------- 1 | from rest_framework.request import Request 2 | from rest_framework.throttling import UserRateThrottle 3 | 4 | 5 | class SuperUserExemptRateThrottle(UserRateThrottle): 6 | 7 | def allow_request(self, request: Request, view): 8 | if request.user.is_superuser: 9 | return True 10 | return super().allow_request(request, view) 11 | 12 | def get_cache_key(self, request, view): 13 | user_agent = request.META.get('HTTP_USER_AGENT', '') 14 | ip_address = self.get_ident(request) 15 | return f'{user_agent}_{ip_address}' 16 | 17 | 18 | class ReactionRateThrottle(SuperUserExemptRateThrottle): 19 | scope = 'review_reaction' 20 | 21 | 22 | class VerifyAuthRateThrottle(SuperUserExemptRateThrottle): 23 | scope = 'verify_auth' 24 | 25 | 26 | class EmailCodeRateThrottle(SuperUserExemptRateThrottle): 27 | scope = 'email_code' 28 | -------------------------------------------------------------------------------- /jcourse/urls.py: -------------------------------------------------------------------------------- 1 | """jcourse URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | 19 | from jcourse.settings import DEBUG 20 | 21 | urlpatterns = [ 22 | path('admin/', admin.site.urls), 23 | path('api-auth/', include('rest_framework.urls')), 24 | path('api/', include('jcourse_api.urls')), 25 | path('oauth/', include('oauth.urls')), 26 | path('api/promotion/', include('ad.urls')), 27 | # path('prometheus/', include('django_prometheus.urls')) 28 | ] 29 | 30 | if DEBUG: 31 | urlpatterns += [path('__debug__/', include('debug_toolbar.urls')), ] 32 | -------------------------------------------------------------------------------- /jcourse/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for jcourse project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'jcourse.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /jcourse_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/jcourse_api/__init__.py -------------------------------------------------------------------------------- /jcourse_api/apps.py: -------------------------------------------------------------------------------- 1 | import jieba 2 | from django.apps import AppConfig 3 | from django.db.models.signals import post_delete, post_save 4 | 5 | 6 | class JcourseApiConfig(AppConfig): 7 | default_auto_field = 'django.db.models.BigAutoField' 8 | name = 'jcourse_api' 9 | verbose_name = '选课社区' 10 | 11 | def ready(self): 12 | jieba.initialize() 13 | 14 | from jcourse_api.models import ReviewReaction, Review, Report 15 | from jcourse_api.signals import signal_delete_review_actions, \ 16 | signal_delete_course_reviews, signal_notify_report_replied 17 | post_delete.connect(signal_delete_review_actions, sender=ReviewReaction) 18 | post_delete.connect(signal_delete_course_reviews, sender=Review) 19 | post_save.connect(signal_notify_report_replied, sender=Report) 20 | # post_save.connect(signal_notify_new_review_generated, sender=Review) 21 | -------------------------------------------------------------------------------- /jcourse_api/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/jcourse_api/management/__init__.py -------------------------------------------------------------------------------- /jcourse_api/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/jcourse_api/management/commands/__init__.py -------------------------------------------------------------------------------- /jcourse_api/management/commands/check_duplicate.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | from django.db.models import Count, Q 3 | 4 | from jcourse_api.models import Course 5 | 6 | 7 | def get_duplicated(): 8 | dups = Course.objects.values("main_teacher", "name", "credit").annotate(count=Count('id')).filter(count__gt=1) 9 | conditions = Q(pk=None) 10 | for dup in dups: 11 | conditions = conditions | Q(main_teacher=dup['main_teacher'], name=dup['name'], credit=dup['credit']) 12 | courses = Course.objects.filter(conditions).order_by("name", "main_teacher", "id") 13 | return courses 14 | 15 | 16 | class Command(BaseCommand): 17 | help = 'Check duplicated courses' 18 | 19 | def handle(self, *args, **options): 20 | courses = get_duplicated().select_related("main_teacher") 21 | for course in courses: 22 | self.stdout.write(f"{course.code} {course.name} {course.main_teacher.name} {course.id}") 23 | -------------------------------------------------------------------------------- /jcourse_api/management/commands/export_courses.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from jcourse_api.utils import export_courses_to_csv 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Export courses\' base information' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument('-o', '--output', type=str, help='output filename') 11 | parser.add_argument('-e', '--encoding', type=str, help='output encoding') 12 | 13 | def handle(self, *args, **options): 14 | if not options['output']: 15 | self.stderr.write('Error: Need to provide output filename') 16 | return 17 | 18 | with open(options['output'], mode='w', newline='') as f: 19 | export_courses_to_csv(f) 20 | -------------------------------------------------------------------------------- /jcourse_api/management/commands/import.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | from tablib import Dataset 3 | 4 | from jcourse_api.admin import * 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Import from csv' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('-f', '--file', type=str, help='filename') 12 | parser.add_argument('-y', '--yes', action="store_true", help='no dry run') 13 | group = parser.add_mutually_exclusive_group() 14 | group.add_argument('-c', '--course', action="store_true", help='import courses') 15 | group.add_argument('-t', '--teacher', action="store_true", help='import teachers') 16 | 17 | def import_course(self, filename: str, dry_run: bool): 18 | course_resource = CourseResource() 19 | with open(filename, mode='r') as f: 20 | dataset = Dataset().load(f) 21 | result = course_resource.import_data(dataset, dry_run) 22 | self.stdout.write(f'{result.has_errors()}') 23 | 24 | def import_teacher(self, filename: str, dry_run: bool): 25 | teacher_resource = TeacherResource() 26 | with open(filename, mode='r') as f: 27 | dataset = Dataset().load(f) 28 | result = teacher_resource.import_data(dataset, dry_run) 29 | self.stdout.write(f'{result.has_errors()}') 30 | 31 | def handle(self, *args, **options): 32 | if options['file']: 33 | if options['course']: 34 | self.import_course(options['file'], not options['yes']) 35 | elif options['teacher']: 36 | self.import_teacher(options['file'], not options['yes']) 37 | else: 38 | self.stdout.write(f'No filename provided!') 39 | -------------------------------------------------------------------------------- /jcourse_api/management/commands/merge_course.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from jcourse_api.utils import merge_teacher_by_id, merge_course_by_id, replace_course_code_multi 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Merge courses' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument('-o', '--old', type=str) 11 | parser.add_argument('-n', '--new', type=str) 12 | group = parser.add_mutually_exclusive_group() 13 | group.add_argument('--code', action="store_true", help='merge by code') 14 | group.add_argument('--cid', action="store_true", help='merge by course id') 15 | group.add_argument('--tid', action="store_true", help='merge by teacher id') 16 | 17 | def print_merge(self, old, new): 18 | self.stdout.write(f'found old {old}, new {new}') 19 | 20 | def print_replace(self, old): 21 | self.stdout.write(f'replace {old}') 22 | 23 | def handle(self, *args, **options): 24 | if options['old'] and options['new']: 25 | if options['code']: 26 | replace_course_code_multi(options['old'], options['new'], self.print_merge, self.print_replace) 27 | elif options['cid']: 28 | merge_course_by_id(options['old'], options['new'], self.print_merge) 29 | 30 | elif options['tid']: 31 | merge_teacher_by_id(options['old'], options['new'], self.print_merge) 32 | else: 33 | self.stdout.write(f'No value provided!') 34 | self.stdout.write('Result: Replaced') 35 | -------------------------------------------------------------------------------- /jcourse_api/management/commands/merge_user.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from jcourse_api.utils import merge_user_by_raw_account, merge_user_by_id 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Merge users' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument('-o', '--old', type=str) 11 | parser.add_argument('-n', '--new', type=str) 12 | group = parser.add_mutually_exclusive_group() 13 | group.add_argument('--uid', action="store_true", help='merge by user id') 14 | group.add_argument('--raw', action="store_true", help='merge by raw account') 15 | 16 | def handle(self, *args, **options): 17 | if options['old'] and options['new']: 18 | if options['uid']: 19 | merge_user_by_id(options['old'], options['new']) 20 | elif options['raw']: 21 | merge_user_by_raw_account(options['old'], options['new']) 22 | else: 23 | self.stdout.write(f'No value provided!') 24 | self.stdout.write('Result: Replaced') 25 | -------------------------------------------------------------------------------- /jcourse_api/management/commands/rename.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from jcourse_api.utils import rename_user_by_name, rename_user_raw_account 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Rename users' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument('-o', '--old', type=str) 11 | parser.add_argument('-n', '--new', type=str) 12 | group = parser.add_mutually_exclusive_group() 13 | group.add_argument('--hash', action="store_true", help='rename by hashed username') 14 | group.add_argument('--raw', action="store_true", help='rename by raw account') 15 | 16 | def handle(self, *args, **options): 17 | if options['old'] and options['new']: 18 | if options['hash']: 19 | rename_user_by_name(options['old'], options['new']) 20 | elif options['raw']: 21 | rename_user_raw_account(options['old'], options['new']) 22 | else: 23 | self.stdout.write(f'No value provided!') 24 | self.stdout.write('Result: renamed') 25 | -------------------------------------------------------------------------------- /jcourse_api/management/commands/update_semester.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from django.core.management import BaseCommand 4 | 5 | from jcourse_api.models import * 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Update semester from csv. Run this by semester desc.' 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument('-f', '--file', type=str, help='filename') 13 | parser.add_argument('-s', '--semester', type=str, help='semester name') 14 | group = parser.add_mutually_exclusive_group() 15 | group.add_argument('-c', '--course', action="store_true", help='import courses') 16 | group.add_argument('-t', '--teacher', action="store_true", help='import teachers') 17 | 18 | def update_course(self, filename: str, semester_name: str): 19 | semester = Semester.objects.get(name=semester_name) 20 | with open(filename, mode='r') as f: 21 | reader = csv.DictReader(f) 22 | for row in reader: 23 | course = Course.objects.filter(last_semester=None, code=row['code'], 24 | main_teacher__tid=row['main_teacher']) 25 | if course.exists(): 26 | course = course.get() 27 | course.last_semester = semester 28 | print(course) 29 | course.save() 30 | 31 | def update_teacher(self, filename: str, semester_name: str): 32 | semester = Semester.objects.get(name=semester_name) 33 | with open(filename, mode='r') as f: 34 | reader = csv.DictReader(f) 35 | for row in reader: 36 | teacher = Teacher.objects.filter(last_semester=None, tid=row['tid']) 37 | if teacher.exists(): 38 | teacher = teacher.get() 39 | teacher.last_semester = semester 40 | print(teacher) 41 | teacher.save() 42 | 43 | def handle(self, *args, **options): 44 | if options['file'] and options['semester']: 45 | if options['course']: 46 | self.update_course(options['file'], options['semester']) 47 | elif options['teacher']: 48 | self.update_teacher(options['file'], options['semester']) 49 | else: 50 | self.stdout.write(f'No filename provided!') 51 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0002_report_reply.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-09-09 05:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='report', 15 | name='reply', 16 | field=models.TextField(blank=True, max_length=817, null=True, verbose_name='回复'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0003_auto_20210913_2317.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-09-13 15:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0002_report_reply'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='apikey', 15 | name='key', 16 | field=models.CharField(max_length=255, unique=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='formercode', 20 | name='old_code', 21 | field=models.CharField(max_length=32, unique=True, verbose_name='旧课号'), 22 | ), 23 | migrations.AddConstraint( 24 | model_name='action', 25 | constraint=models.UniqueConstraint(fields=('user', 'review'), name='unique_action'), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0004_auto_20210915_1849.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-09-15 10:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0003_auto_20210913_2317'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='teacher', 15 | name='abbr_pinyin', 16 | field=models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='拼音缩写'), 17 | ), 18 | migrations.AlterField( 19 | model_name='teacher', 20 | name='pinyin', 21 | field=models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='拼音'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0005_alter_action_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-17 08:11 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0004_auto_20210915_1849'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='action', 15 | options={'verbose_name': '评论点赞', 'verbose_name_plural': '评论点赞'}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0006_alter_teacher_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-17 09:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0005_alter_action_options'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='teacher', 15 | name='title', 16 | field=models.CharField(blank=True, max_length=64, null=True, verbose_name='职称'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0007_auto_20210922_2212.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-22 14:12 2 | 3 | from django.db import migrations, models 4 | from django.db.models import Count, Avg 5 | 6 | 7 | def compute_course_review(apps, schema_editor): 8 | Course = apps.get_model('jcourse_api', 'Course') 9 | Review = apps.get_model('jcourse_api', 'Review') 10 | courses = Course.objects.annotate(count=Count('review')).filter(count__gt=0) 11 | for course in courses: 12 | reviews = Review.objects.filter(course=course) 13 | course.review_count = reviews.count() 14 | course.review_avg = reviews.aggregate(avg=Avg('rating'))['avg'] 15 | course.save() 16 | 17 | 18 | def compute_review_action(apps, schema_editor): 19 | Review = apps.get_model('jcourse_api', 'Review') 20 | Action = apps.get_model('jcourse_api', 'Action') 21 | reviews = Review.objects.annotate(count=Count('action')).filter(count__gt=0) 22 | for review in reviews: 23 | actions = Action.objects.filter(review=review) 24 | review.approve_count = actions.filter(action=1).count() 25 | review.disapprove_count = actions.filter(action=-1).count() 26 | review.save() 27 | 28 | 29 | class Migration(migrations.Migration): 30 | dependencies = [ 31 | ('jcourse_api', '0006_alter_teacher_title'), 32 | ] 33 | 34 | operations = [ 35 | migrations.RemoveField( 36 | model_name='review', 37 | name='available', 38 | ), 39 | migrations.AddField( 40 | model_name='course', 41 | name='review_avg', 42 | field=models.FloatField(blank=True, default=0, null=True, verbose_name='平均评分'), 43 | ), 44 | migrations.AddField( 45 | model_name='course', 46 | name='review_count', 47 | field=models.IntegerField(blank=True, default=0, null=True, verbose_name='点评数'), 48 | ), 49 | migrations.AddField( 50 | model_name='review', 51 | name='approve_count', 52 | field=models.IntegerField(blank=True, default=0, null=True, verbose_name='获赞数'), 53 | ), 54 | migrations.AddField( 55 | model_name='review', 56 | name='disapprove_count', 57 | field=models.IntegerField(blank=True, default=0, null=True, verbose_name='获踩数'), 58 | ), 59 | migrations.RunPython(compute_course_review), 60 | migrations.RunPython(compute_review_action) 61 | ] 62 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0008_alter_review_rating.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-23 13:31 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | def update_review_rating(apps, schema_editor): 8 | Review = apps.get_model('jcourse_api', 'Review') 9 | Review.objects.filter(rating=0).update(rating=1) 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [ 14 | ('jcourse_api', '0007_auto_20210922_2212'), 15 | ] 16 | 17 | operations = [ 18 | migrations.RunPython(update_review_rating), 19 | migrations.AlterField( 20 | model_name='review', 21 | name='rating', 22 | field=models.IntegerField( 23 | validators=[django.core.validators.MaxValueValidator(5), django.core.validators.MinValueValidator(1)], 24 | verbose_name='推荐指数'), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0009_alter_review_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-21 12:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0008_alter_review_rating'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='review', 15 | name='comment', 16 | field=models.TextField(max_length=817, verbose_name='详细点评'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0010_auto_20211207_1456.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-07 06:56 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0009_alter_review_comment'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='course', 15 | name='language', 16 | ), 17 | migrations.DeleteModel( 18 | name='Language', 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0011_auto_20211215_1803.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2021-12-15 10:03 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('jcourse_api', '0010_auto_20211207_1456'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='action', 15 | name='action', 16 | field=models.IntegerField(choices=[(1, '赞同'), (-1, '反对'), (0, '重置')], db_index=True, default=0, 17 | verbose_name='操作'), 18 | ), 19 | migrations.AlterField( 20 | model_name='apikey', 21 | name='key', 22 | field=models.CharField(db_index=True, max_length=255, unique=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='course', 26 | name='review_count', 27 | field=models.IntegerField(blank=True, db_index=True, default=0, null=True, verbose_name='点评数'), 28 | ), 29 | migrations.AlterField( 30 | model_name='formercode', 31 | name='new_code', 32 | field=models.CharField(db_index=True, max_length=32, verbose_name='新课号'), 33 | ), 34 | migrations.AlterField( 35 | model_name='formercode', 36 | name='old_code', 37 | field=models.CharField(db_index=True, max_length=32, unique=True, verbose_name='旧课号'), 38 | ), 39 | migrations.AlterField( 40 | model_name='report', 41 | name='created', 42 | field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='发布时间'), 43 | ), 44 | migrations.AlterField( 45 | model_name='report', 46 | name='solved', 47 | field=models.BooleanField(db_index=True, default=False, verbose_name='是否解决'), 48 | ), 49 | migrations.AlterField( 50 | model_name='review', 51 | name='created', 52 | field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='发布时间'), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0012_alter_action_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2021-12-16 08:52 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0011_auto_20211215_1803'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name='action', 14 | options={'verbose_name': '点评点赞', 'verbose_name_plural': '点评点赞'}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0013_auto_20211216_1702.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2021-12-16 09:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def update_filter_count(apps, schema_editor): 7 | Course = apps.get_model('jcourse_api', 'Course') 8 | Category = apps.get_model('jcourse_api', 'Category') 9 | Department = apps.get_model('jcourse_api', 'Department') 10 | categories = Category.objects.all() 11 | departments = Department.objects.all() 12 | for category in categories: 13 | category.count = Course.objects.filter(category=category).count() 14 | category.save() 15 | for department in departments: 16 | department.count = Course.objects.filter(department=department).count() 17 | department.save() 18 | 19 | 20 | class Migration(migrations.Migration): 21 | dependencies = [ 22 | ('jcourse_api', '0012_alter_action_options'), 23 | ] 24 | 25 | operations = [ 26 | migrations.AddField( 27 | model_name='category', 28 | name='count', 29 | field=models.IntegerField(default=0, verbose_name='课程数量'), 30 | ), 31 | migrations.AddField( 32 | model_name='department', 33 | name='count', 34 | field=models.IntegerField(default=0, verbose_name='课程数量'), 35 | ), 36 | migrations.RunPython(update_filter_count), 37 | ] 38 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0014_userpoint.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2021-12-22 14:10 2 | from datetime import datetime, timezone 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | from django.db.models import Sum 9 | 10 | 11 | def make_up_old_point(apps, schema_editor): 12 | Review = apps.get_model('jcourse_api', 'Review') 13 | UserPoint = apps.get_model('jcourse_api', 'UserPoint') 14 | reviews = Review.objects.filter(created__lte=datetime(2021, 12, 12, tzinfo=timezone.utc)) 15 | users = reviews.order_by('user').distinct('user').values_list('user', flat=True) 16 | for user in users: 17 | user_reviews = reviews.filter(user_id=user) 18 | courses = user_reviews.values_list('course', flat=True) 19 | approves_count = user_reviews.aggregate(count=Sum('approve_count'))['count'] 20 | if approves_count is None: 21 | approves_count = 0 22 | first_reviews = reviews.filter(course__in=courses).order_by('course_id', 'created').distinct( 23 | 'course_id').values_list('id', flat=True) 24 | first_reviews = first_reviews.intersection(user_reviews) 25 | first_reviews_approves_count = reviews.filter(pk__in=first_reviews).aggregate(count=Sum('approve_count'))[ 26 | 'count'] 27 | if first_reviews_approves_count is None: 28 | first_reviews_approves_count = 0 29 | # print(user, approves_count, first_reviews_approves_count) 30 | value = approves_count + first_reviews_approves_count 31 | UserPoint.objects.create(user_id=user, value=value, description='升级补偿') 32 | 33 | 34 | class Migration(migrations.Migration): 35 | dependencies = [ 36 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 37 | ('jcourse_api', '0013_auto_20211216_1702'), 38 | ] 39 | 40 | operations = [ 41 | migrations.CreateModel( 42 | name='UserPoint', 43 | fields=[ 44 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 45 | ('value', models.IntegerField(default=0, verbose_name='数值')), 46 | ('description', models.CharField(max_length=255, verbose_name='原因')), 47 | ('time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='时间')), 48 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 49 | ], 50 | options={ 51 | 'verbose_name': '积分', 52 | 'verbose_name_plural': '积分', 53 | }, 54 | ), 55 | migrations.RunPython(make_up_old_point), 56 | ] 57 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0015_alter_userpoint_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2021-12-26 05:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0014_userpoint'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='userpoint', 14 | name='description', 15 | field=models.CharField(max_length=255, null=True, verbose_name='原因'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0016_alter_enrollcourse_user_alter_userpoint_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2022-01-05 12:21 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('auth', '0012_alter_user_first_name_max_length'), 10 | ('jcourse_api', '0015_alter_userpoint_description'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='enrollcourse', 16 | name='user', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.user', verbose_name='用户'), 18 | ), 19 | migrations.AlterField( 20 | model_name='userpoint', 21 | name='user', 22 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.user', verbose_name='用户'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0017_review_modified.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-07 07:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0016_alter_enrollcourse_user_alter_userpoint_user'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='review', 14 | name='modified', 15 | field=models.DateTimeField(db_index=True, blank=True, null=True, verbose_name='修改时间'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0018_course_last_semester_teacher_last_semester.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-07 13:40 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('jcourse_api', '0017_review_modified'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='course', 15 | name='last_semester', 16 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, 17 | to='jcourse_api.semester', verbose_name='最后更新学期'), 18 | ), 19 | migrations.AddField( 20 | model_name='teacher', 21 | name='last_semester', 22 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, 23 | to='jcourse_api.semester', verbose_name='最后更新学期'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0019_alter_course_review_avg_alter_review_approve_count_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-16 09:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0018_course_last_semester_teacher_last_semester'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='course', 14 | name='review_avg', 15 | field=models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='平均评分'), 16 | ), 17 | migrations.AlterField( 18 | model_name='review', 19 | name='approve_count', 20 | field=models.IntegerField(blank=True, db_index=True, default=0, null=True, verbose_name='获赞数'), 21 | ), 22 | migrations.AlterField( 23 | model_name='review', 24 | name='disapprove_count', 25 | field=models.IntegerField(blank=True, db_index=True, default=0, null=True, verbose_name='获踩数'), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0020_alter_review_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-16 10:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0019_alter_course_review_avg_alter_review_approve_count_and_more'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='review', 14 | name='comment', 15 | field=models.TextField(max_length=1926, verbose_name='详细点评'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0021_semester_available.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-17 13:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0020_alter_review_comment'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='semester', 14 | name='available', 15 | field=models.BooleanField(default=True, verbose_name='用户可选'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0022_update_point_rule.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-03-03 19:00 2 | from django.contrib.auth.models import User 3 | from django.db import migrations 4 | from django.db.models import F 5 | 6 | from jcourse_api.models import Review, UserPoint 7 | from jcourse_api.utils.point import get_user_point_with_reviews 8 | from jcourse_api.views import get_user_point 9 | 10 | 11 | def get_old_point(user: User): 12 | reviews = Review.objects.filter(user=user) 13 | return get_user_point_with_reviews(user, reviews) 14 | 15 | 16 | def make_up_old_point(apps, schema_editor): 17 | reviews = Review.objects.filter(disapprove_count__gt=F('approve_count') * 2) 18 | user_ids = reviews.order_by('user').distinct('user').values_list('user', flat=True) 19 | for user_id in user_ids: 20 | user = User.objects.get(pk=user_id) 21 | old_point, _ = get_old_point(user) 22 | new_point = get_user_point(user) 23 | value = old_point['points'] - new_point['points'] 24 | UserPoint.objects.create(user=user, value=value, description='升级补偿') 25 | print(user, old_point, new_point) 26 | 27 | 28 | class Migration(migrations.Migration): 29 | dependencies = [ 30 | ('jcourse_api', '0021_semester_available'), 31 | ] 32 | 33 | operations = [ 34 | migrations.RunPython(make_up_old_point), 35 | ] 36 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0023_notice_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-07-10 09:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0022_update_point_rule'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='notice', 14 | name='url', 15 | field=models.TextField(max_length=256, null=True, blank=True, verbose_name='链接'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0024_alter_review_ordered_rule_.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-16 09:19 2 | 3 | from django.db import migrations 4 | 5 | 6 | def create_modified_time(apps, schema_editor): 7 | Review = apps.get_model('jcourse_api', 'Review') 8 | for review in Review.objects.filter(modified=None): 9 | created_time = review.created 10 | review.modified = created_time 11 | review.save() 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ('jcourse_api', '0023_notice_url'), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(create_modified_time), 21 | migrations.AlterModelOptions( 22 | name='review', 23 | options={'ordering': ['-modified'], 'verbose_name': '点评', 'verbose_name_plural': '点评'}, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0025_remove_course_category_course_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-23 08:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def move_category_to_categories(apps, schema_editor): 7 | Course = apps.get_model('jcourse_api', 'Course') 8 | courses = Course.objects.all() 9 | for course in courses: 10 | category = course.category 11 | if category: 12 | course.categories.add(category) 13 | course.save() 14 | 15 | 16 | class Migration(migrations.Migration): 17 | dependencies = [ 18 | ('jcourse_api', '0024_alter_review_ordered_rule_'), 19 | ] 20 | 21 | operations = [ 22 | migrations.AddField( 23 | model_name='course', 24 | name='categories', 25 | field=models.ManyToManyField(db_index=True, to='jcourse_api.category', verbose_name='类别'), 26 | ), 27 | migrations.RunPython(move_category_to_categories), 28 | migrations.RemoveField( 29 | model_name='course', 30 | name='category', 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0026_remove_category_count_remove_department_count.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-24 09:04 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0025_remove_course_category_course_categories'), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name='category', 14 | name='count', 15 | ), 16 | migrations.RemoveField( 17 | model_name='department', 18 | name='count', 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0027_alter_course_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2022-08-29 15:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('jcourse_api', '0026_remove_category_count_remove_department_count'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='course', 14 | name='categories', 15 | field=models.ManyToManyField(blank=True, db_index=True, to='jcourse_api.category', verbose_name='类别'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0028_alter_review_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-09-15 08:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0027_alter_course_categories'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='review', 15 | name='comment', 16 | field=models.TextField(max_length=9681, verbose_name='详细点评'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0029_reviewrevision.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-10-24 05:49 2 | 3 | from django.conf import settings 4 | import django.core.validators 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('jcourse_api', '0028_alter_review_comment'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='ReviewRevision', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('rating', models.IntegerField(validators=[django.core.validators.MaxValueValidator(5), django.core.validators.MinValueValidator(1)], verbose_name='推荐指数')), 23 | ('comment', models.TextField(max_length=9681, verbose_name='详细点评')), 24 | ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='修订时间')), 25 | ('score', models.CharField(blank=True, max_length=10, null=True, verbose_name='成绩')), 26 | ('course', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='jcourse_api.course', verbose_name='课程')), 27 | ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='jcourse_api.review', verbose_name='点评')), 28 | ('semester', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='jcourse_api.semester', verbose_name='上课学期')), 29 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='执行用户')), 30 | ], 31 | options={ 32 | 'verbose_name': '点评修订记录', 33 | 'verbose_name_plural': '点评修订记录', 34 | }, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0030_alter_action_options_action_modified.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-10-24 06:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0029_reviewrevision'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='action', 15 | options={'ordering': ['-modified'], 'verbose_name': '点评点赞', 'verbose_name_plural': '点评点赞'}, 16 | ), 17 | migrations.AddField( 18 | model_name='action', 19 | name='modified', 20 | field=models.DateTimeField(auto_now=True, db_index=True, null=True, verbose_name='修改时间'), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0031_rename_notice_announcement_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-10-24 06:23 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0030_alter_action_options_action_modified'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameModel( 14 | old_name='Notice', 15 | new_name='Announcement', 16 | ), 17 | migrations.AlterModelOptions( 18 | name='announcement', 19 | options={'ordering': ['-created'], 'verbose_name': '公告', 'verbose_name_plural': '公告'}, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0032_rename_action_reviewreaction.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-01 06:09 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('jcourse_api', '0031_rename_notice_announcement_and_more'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameModel( 16 | old_name='Action', 17 | new_name='ReviewReaction', 18 | ), 19 | migrations.AlterModelOptions( 20 | name='reviewreaction', 21 | options={'ordering': ['-modified'], 'verbose_name': '点评回应', 'verbose_name_plural': '点评回应'}, 22 | ), 23 | migrations.RenameField( 24 | model_name='reviewreaction', 25 | old_name='action', 26 | new_name='reaction', 27 | ), 28 | migrations.RemoveConstraint( 29 | model_name='reviewreaction', 30 | name='unique_action', 31 | ), 32 | migrations.AddConstraint( 33 | model_name='reviewreaction', 34 | constraint=models.UniqueConstraint(fields=('user', 'review'), name='unique_reaction'), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0033_notification.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-02 06:40 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('contenttypes', '0002_remove_content_type_name'), 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('jcourse_api', '0032_rename_action_reviewreaction'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Notification', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('type', models.IntegerField(blank=True, choices=[(0, '管理员回复'), (1, '获得点赞'), (2, '积分失效'), (3, '积分补偿'), (4, '点评被回复'), (5, '点评被引用'), (6, '点评被删除'), (7, '反馈被回复'), (8, '关注的课程有新点评')], db_index=True, null=True, verbose_name='类型')), 23 | ('description', models.TextField(blank=True, null=True, verbose_name='内容')), 24 | ('object_id', models.PositiveIntegerField(null=True, verbose_name='内容ID')), 25 | ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间')), 26 | ('read_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='阅读时间')), 27 | ('public', models.BooleanField(db_index=True, default=True, verbose_name='已发布')), 28 | ('content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='内容类型')), 29 | ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='接收者')), 30 | ], 31 | options={ 32 | 'verbose_name': '通知', 33 | 'verbose_name_plural': '通知', 34 | 'ordering': ('-created',), 35 | }, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0034_alter_reviewreaction_reaction.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-03 06:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0033_notification'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='reviewreaction', 15 | name='reaction', 16 | field=models.IntegerField(choices=[(0, '重置'), (1, '赞同'), (-1, '反对')], db_index=True, default=0, verbose_name='操作'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0035_enrollcourse_created.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-03 06:55 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('jcourse_api', '0034_alter_reviewreaction_reaction'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='enrollcourse', 16 | name='created', 17 | field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0036_coursenotificationlevel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-04 05:34 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ("jcourse_api", "0035_enrollcourse_created"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="CourseNotificationLevel", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ( 30 | "notification_level", 31 | models.IntegerField( 32 | blank=True, 33 | choices=[(0, "正常"), (1, "关注"), (2, "忽略")], 34 | default=0, 35 | verbose_name="通知等级", 36 | ), 37 | ), 38 | ( 39 | "modified", 40 | models.DateTimeField( 41 | default=django.utils.timezone.now, verbose_name="改动时间" 42 | ), 43 | ), 44 | ( 45 | "course", 46 | models.ForeignKey( 47 | blank=True, 48 | on_delete=django.db.models.deletion.CASCADE, 49 | to="jcourse_api.course", 50 | verbose_name="课程", 51 | ), 52 | ), 53 | ( 54 | "user", 55 | models.ForeignKey( 56 | blank=True, 57 | on_delete=django.db.models.deletion.CASCADE, 58 | to=settings.AUTH_USER_MODEL, 59 | verbose_name="用户", 60 | ), 61 | ), 62 | ], 63 | options={"verbose_name": "课程通知等级", "verbose_name_plural": "课程通知等级",}, 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0037_alter_announcement_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-31 06:07 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0036_coursenotificationlevel'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='announcement', 15 | options={'ordering': ['-created_at'], 'verbose_name': '公告', 'verbose_name_plural': '公告'}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='notification', 19 | options={'ordering': ('-created_at',), 'verbose_name': '通知', 'verbose_name_plural': '通知'}, 20 | ), 21 | migrations.AlterModelOptions( 22 | name='report', 23 | options={'ordering': ['-created_at'], 'verbose_name': '反馈', 'verbose_name_plural': '反馈'}, 24 | ), 25 | migrations.AlterModelOptions( 26 | name='review', 27 | options={'ordering': ['-modified_at'], 'verbose_name': '点评', 'verbose_name_plural': '点评'}, 28 | ), 29 | migrations.AlterModelOptions( 30 | name='reviewreaction', 31 | options={'ordering': ['-modified_at'], 'verbose_name': '点评回应', 'verbose_name_plural': '点评回应'}, 32 | ), 33 | migrations.RenameField( 34 | model_name='announcement', 35 | old_name='created', 36 | new_name='created_at', 37 | ), 38 | migrations.RenameField( 39 | model_name='apikey', 40 | old_name='last_modified', 41 | new_name='modified_at', 42 | ), 43 | migrations.RenameField( 44 | model_name='coursenotificationlevel', 45 | old_name='modified', 46 | new_name='modified_at', 47 | ), 48 | migrations.RenameField( 49 | model_name='enrollcourse', 50 | old_name='created', 51 | new_name='created_at', 52 | ), 53 | migrations.RenameField( 54 | model_name='notification', 55 | old_name='created', 56 | new_name='created_at', 57 | ), 58 | migrations.RenameField( 59 | model_name='report', 60 | old_name='created', 61 | new_name='created_at', 62 | ), 63 | migrations.RenameField( 64 | model_name='review', 65 | old_name='created', 66 | new_name='created_at', 67 | ), 68 | migrations.RenameField( 69 | model_name='review', 70 | old_name='modified', 71 | new_name='modified_at', 72 | ), 73 | migrations.RenameField( 74 | model_name='reviewreaction', 75 | old_name='modified', 76 | new_name='modified_at', 77 | ), 78 | migrations.RenameField( 79 | model_name='reviewrevision', 80 | old_name='created', 81 | new_name='created_at', 82 | ), 83 | ] 84 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0038_formercode_unique_record.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-13 13:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('jcourse_api', '0037_alter_announcement_options_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name='formercode', 15 | constraint=models.UniqueConstraint(fields=('old_code', 'new_code'), name='unique_record'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0039_review_search_vector.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-12 12:59 2 | import django 3 | from django.contrib.postgres.search import SearchVector 4 | from django.db import migrations 5 | 6 | from utils.cut_word import get_cut_word_search_vector 7 | 8 | 9 | def cut_for_exist_reviews(apps, schema_editor): 10 | Review = apps.get_model("jcourse_api", "Review") 11 | reviews = Review.objects.all() 12 | for review in reviews: 13 | review.search_vector = get_cut_word_search_vector(review.comment) 14 | review.save(update_fields=["search_vector"]) 15 | print(review.id) 16 | 17 | 18 | class Migration(migrations.Migration): 19 | dependencies = [ 20 | ('jcourse_api', '0038_formercode_unique_record'), 21 | ] 22 | 23 | operations = [ 24 | migrations.AddField( 25 | model_name='review', 26 | name='search_vector', 27 | field=django.contrib.postgres.search.SearchVectorField(editable=False, null=True), 28 | ), 29 | migrations.RunPython(cut_for_exist_reviews, reverse_code=migrations.RunPython.noop) 30 | ] 31 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0040_review_jcourse_api_search__43d524_gin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-12 13:50 2 | 3 | import django.contrib.postgres.indexes 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('jcourse_api', '0039_review_search_vector'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddIndex( 15 | model_name='review', 16 | index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='jcourse_api_search__43d524_gin'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0041_alter_reviewrevision_review.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-25 13:58 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('jcourse_api', '0040_review_jcourse_api_search__43d524_gin'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='reviewrevision', 16 | name='review', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='jcourse_api.review', verbose_name='点评'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0042_no_point_by_review.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-12-01 14:54 2 | from django.contrib.auth.models import User 3 | from django.db import migrations 4 | from django.db.models import F 5 | 6 | from jcourse_api.models import Review 7 | from jcourse_api.utils.point import get_user_point_with_reviews 8 | 9 | 10 | def get_user_point(user: User): 11 | reviews = Review.objects.filter(user=user).exclude(disapprove_count__gt=F('approve_count') * 2) 12 | return get_user_point_with_reviews(user, reviews) 13 | 14 | 15 | def get_all_users_point(apps, schema_editor): 16 | UserPoint = apps.get_model('jcourse_api', 'UserPoint') 17 | users = User.objects.all() 18 | for user in users: 19 | old_point, new_point = get_user_point(user) 20 | diff = old_point - new_point 21 | if diff > 0: 22 | UserPoint.objects.create(user_id=user.id, value=diff, description='历史积分补偿') 23 | 24 | 25 | class Migration(migrations.Migration): 26 | dependencies = [ 27 | ('jcourse_api', '0041_alter_reviewrevision_review'), 28 | ] 29 | 30 | operations = [ 31 | migrations.RunPython(get_all_users_point, reverse_code=migrations.RunPython.noop) 32 | ] 33 | -------------------------------------------------------------------------------- /jcourse_api/migrations/0043_alter_reviewrevision_review.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.1 on 2025-05-26 11:24 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('jcourse_api', '0042_no_point_by_review'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='reviewrevision', 16 | name='review', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='jcourse_api.review', verbose_name='点评'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /jcourse_api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/jcourse_api/migrations/__init__.py -------------------------------------------------------------------------------- /jcourse_api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .course import * 3 | from .review import * 4 | from .user import * 5 | from .site import * 6 | from .notification import * 7 | from .course_notification_level import * 8 | -------------------------------------------------------------------------------- /jcourse_api/models/base.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Department(models.Model): 5 | class Meta: 6 | verbose_name = '教学单位' 7 | ordering = ['name'] 8 | verbose_name_plural = verbose_name 9 | 10 | name = models.CharField(verbose_name='名称', max_length=64, unique=True) 11 | 12 | def __str__(self): 13 | return self.name 14 | 15 | 16 | def constrain_text(text: str) -> str: 17 | if len(str(text)) > 40: # 字数自己设置 18 | return '{}……'.format(str(text)[0:40]) # 超出部分以省略号代替。 19 | else: 20 | return str(text) 21 | 22 | 23 | class Semester(models.Model): 24 | class Meta: 25 | verbose_name = '学期' 26 | verbose_name_plural = verbose_name 27 | ordering = ['-name'] 28 | 29 | name = models.CharField(verbose_name='名称', max_length=64, unique=True) 30 | available = models.BooleanField(verbose_name='用户可选', default=True) 31 | 32 | def __str__(self): 33 | return self.name 34 | -------------------------------------------------------------------------------- /jcourse_api/models/course.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from jcourse_api.models import Department, Semester 4 | 5 | 6 | class Category(models.Model): 7 | class Meta: 8 | verbose_name = '课程类别' 9 | ordering = ['name'] 10 | verbose_name_plural = verbose_name 11 | 12 | name = models.CharField(verbose_name='名称', max_length=64, unique=True) 13 | 14 | def __str__(self): 15 | return self.name 16 | 17 | 18 | class FormerCode(models.Model): 19 | class Meta: 20 | verbose_name = '曾用课号' 21 | verbose_name_plural = verbose_name 22 | ordering = ['old_code'] 23 | constraints = [models.UniqueConstraint(fields=['old_code', 'new_code'], name='unique_record')] 24 | 25 | old_code = models.CharField(verbose_name='旧课号', max_length=32, unique=True, db_index=True) 26 | new_code = models.CharField(verbose_name='新课号', max_length=32, db_index=True) 27 | 28 | def __str__(self): 29 | return self.old_code 30 | 31 | 32 | class Teacher(models.Model): 33 | class Meta: 34 | verbose_name = '教师' 35 | verbose_name_plural = verbose_name 36 | ordering = ['name'] 37 | constraints = [models.UniqueConstraint(fields=['tid', 'name'], name='unique_teacher')] 38 | 39 | tid = models.CharField(verbose_name='工号', max_length=32, null=True, blank=True, unique=True) 40 | name = models.CharField(verbose_name='姓名', max_length=255, db_index=True) 41 | department = models.ForeignKey(Department, on_delete=models.SET_NULL, verbose_name='单位', null=True, blank=True) 42 | title = models.CharField(verbose_name='职称', max_length=64, null=True, blank=True) 43 | pinyin = models.CharField(verbose_name='拼音', max_length=64, null=True, blank=True, db_index=True) 44 | abbr_pinyin = models.CharField(verbose_name='拼音缩写', max_length=64, null=True, blank=True, db_index=True) 45 | # 仅用于后台维护,不对外显示 46 | last_semester = models.ForeignKey(Semester, verbose_name='最后更新学期', null=True, blank=True, 47 | on_delete=models.SET_NULL) 48 | 49 | def __str__(self): 50 | return self.name 51 | 52 | 53 | class Course(models.Model): 54 | class Meta: 55 | verbose_name = '课程' 56 | verbose_name_plural = verbose_name 57 | ordering = ['code'] 58 | constraints = [models.UniqueConstraint(fields=['code', 'main_teacher'], name='unique_course')] 59 | 60 | code = models.CharField(verbose_name='课号', max_length=32, db_index=True) 61 | name = models.CharField(verbose_name='名称', max_length=255, db_index=True) 62 | categories = models.ManyToManyField(Category, verbose_name='类别', db_index=True, blank=True) 63 | department = models.ForeignKey(Department, on_delete=models.SET_NULL, verbose_name='开课单位', 64 | null=True, blank=True, db_index=True) 65 | credit = models.FloatField(verbose_name='学分', default=0) 66 | main_teacher = models.ForeignKey(Teacher, verbose_name='主讲教师', on_delete=models.CASCADE, db_index=True) 67 | teacher_group = models.ManyToManyField(Teacher, verbose_name='教师组成', related_name='teacher_course') 68 | moderator_remark = models.TextField(verbose_name='管理员批注', null=True, blank=True, max_length=817) 69 | review_count = models.IntegerField(verbose_name='点评数', null=True, blank=True, default=0, db_index=True) 70 | review_avg = models.FloatField(verbose_name='平均评分', null=True, blank=True, default=0, db_index=True) 71 | # 仅用于后台维护,不对外显示 72 | last_semester = models.ForeignKey(Semester, verbose_name='最后更新学期', null=True, blank=True, 73 | on_delete=models.SET_NULL) 74 | 75 | def __str__(self): 76 | return f"{self.code} {self.name}({self.main_teacher})" 77 | 78 | def category_names(self): 79 | return ','.join(self.categories.all().values_list('name', flat=True)) 80 | 81 | category_names.short_description = '类别' 82 | -------------------------------------------------------------------------------- /jcourse_api/models/course_notification_level.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | from django.db.models import UniqueConstraint 4 | from django.utils import timezone 5 | 6 | from jcourse_api.models import Course, send_course_new_review_notification 7 | 8 | 9 | class CourseNotificationLevel(models.Model): 10 | class Meta: 11 | verbose_name = '课程通知等级' 12 | verbose_name_plural = verbose_name 13 | UniqueConstraint(fields=['course', 'user'], name='unique_course_user') 14 | 15 | class NotificationLevelType(models.IntegerChoices): 16 | NORMAL = 0, '正常' 17 | FOLLOW = 1, '关注' 18 | IGNORE = 2, '忽略' 19 | 20 | user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户', db_index=True, blank=True) 21 | course = models.ForeignKey(Course, on_delete=models.CASCADE, verbose_name='课程', db_index=True, blank=True) 22 | notification_level = models.IntegerField(verbose_name='通知等级', choices=NotificationLevelType.choices, 23 | default=NotificationLevelType.NORMAL, blank=True) 24 | modified_at = models.DateTimeField(verbose_name='改动时间', default=timezone.now) 25 | 26 | def __str__(self): 27 | return f"{self.user}-{self.get_notification_level_display()}-{self.course}" 28 | 29 | 30 | def find_course_new_review(course: Course): 31 | for course_notification_level in CourseNotificationLevel.objects.filter( 32 | course=course, 33 | notification_level=CourseNotificationLevel.NotificationLevelType.FOLLOW 34 | ): 35 | send_course_new_review_notification(course_notification_level.user, course) 36 | -------------------------------------------------------------------------------- /jcourse_api/models/notification.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.models import User 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.utils import timezone 7 | 8 | from jcourse_api.models import Report, Course 9 | 10 | 11 | class Notification(models.Model): 12 | class NotificationType(models.IntegerChoices): 13 | ADMIN_REPLY = 0, '管理员回复' 14 | GET_LIKES = 1, '获得点赞' 15 | POINTS_INVALID = 2, '积分失效' 16 | POINTS_COMPENSATE = 3, '积分补偿' 17 | REVIEWS_REPLIED = 4, '点评被回复' 18 | REVIEWS_QUOTED = 5, '点评被引用' 19 | REVIEWS_REMOVED = 6, '点评被删除' 20 | REPORTS_REPLIED = 7, '反馈被回复' 21 | COURSES_NEW_REVIEW = 8, '关注的课程有新点评' 22 | 23 | class Meta: 24 | verbose_name = '通知' 25 | verbose_name_plural = verbose_name 26 | ordering = ('-created_at',) 27 | 28 | recipient = models.ForeignKey( 29 | User, 30 | on_delete=models.CASCADE, 31 | verbose_name='接收者', 32 | db_index=True 33 | ) 34 | type = models.IntegerField(verbose_name='类型', choices=NotificationType.choices, 35 | db_index=True, null=True, blank=True) 36 | description = models.TextField(blank=True, null=True, verbose_name='内容') 37 | content_type = models.ForeignKey(ContentType, models.CASCADE, verbose_name='内容类型', null=True) 38 | object_id = models.PositiveIntegerField(verbose_name='内容ID', null=True) 39 | related_object = GenericForeignKey('content_type', 'object_id') 40 | created_at = models.DateTimeField(default=timezone.now, db_index=True, verbose_name='创建时间') 41 | read_at = models.DateTimeField(blank=True, null=True, db_index=True, verbose_name='阅读时间') 42 | public = models.BooleanField(default=True, db_index=True, verbose_name='已发布') 43 | 44 | @admin.display(description='已读', boolean=True) 45 | def read(self): 46 | return self.read_at is not None 47 | 48 | def __str__(self): 49 | return f"{self.id}" 50 | 51 | 52 | def send_report_replied_notification(report: Report): 53 | if report.reply: 54 | Notification.objects.create( 55 | recipient=report.user, 56 | type=Notification.NotificationType.REPORTS_REPLIED, 57 | content_type=ContentType.objects.get_for_model(report), 58 | object_id=report.id, 59 | created_at=timezone.now() 60 | ) 61 | 62 | 63 | def send_course_new_review_notification(user: User, course: Course): 64 | Notification.objects.create( 65 | recipient=user, 66 | type=Notification.NotificationType.COURSES_NEW_REVIEW, 67 | content_type=ContentType.objects.get_for_model(course), 68 | object_id=course.id, 69 | created_at=timezone.now() 70 | ) 71 | -------------------------------------------------------------------------------- /jcourse_api/models/review.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.contrib.postgres.indexes import GinIndex 3 | from django.contrib.postgres.search import SearchVectorField 4 | from django.core.validators import MaxValueValidator, MinValueValidator 5 | from django.db import models 6 | from django.db.models import Count, Avg, Q 7 | from django.utils import timezone 8 | 9 | from jcourse_api.models import constrain_text, Course, Semester 10 | from utils.cut_word import get_cut_word_search_vector 11 | 12 | 13 | class Review(models.Model): 14 | class Meta: 15 | verbose_name = '点评' 16 | verbose_name_plural = verbose_name 17 | ordering = ['-modified_at'] 18 | constraints = [models.UniqueConstraint(fields=['user', 'course'], name='unique_review')] 19 | indexes = [GinIndex(fields=['search_vector'])] 20 | 21 | user = models.ForeignKey(User, verbose_name='用户', on_delete=models.CASCADE, db_index=True) 22 | course = models.ForeignKey(Course, verbose_name='课程', on_delete=models.CASCADE, db_index=True) 23 | semester = models.ForeignKey(Semester, verbose_name='上课学期', on_delete=models.SET_NULL, null=True) 24 | rating = models.IntegerField(verbose_name='推荐指数', validators=[MaxValueValidator(5), MinValueValidator(1)]) 25 | comment = models.TextField(verbose_name='详细点评', max_length=9681) 26 | created_at = models.DateTimeField(verbose_name='发布时间', default=timezone.now, db_index=True) 27 | modified_at = models.DateTimeField(verbose_name='修改时间', blank=True, null=True, db_index=True) 28 | score = models.CharField(verbose_name='成绩', null=True, blank=True, max_length=10) 29 | moderator_remark = models.TextField(verbose_name='管理员批注', null=True, blank=True, max_length=817) 30 | approve_count = models.IntegerField(verbose_name='获赞数', null=True, blank=True, default=0, db_index=True) 31 | disapprove_count = models.IntegerField(verbose_name='获踩数', null=True, blank=True, default=0, db_index=True) 32 | search_vector = SearchVectorField(null=True, editable=False) 33 | 34 | def __str__(self): 35 | return f"{self.user} 点评 {self.course}:{constrain_text(self.comment)}" 36 | 37 | def comment_validity(self): 38 | return constrain_text(self.comment) 39 | 40 | comment_validity.short_description = '详细点评' 41 | 42 | def save(self, force_insert=False, force_update=False, using=None, update_fields=None): 43 | need_to_update = False 44 | old_course = None 45 | if self.pk is None: 46 | need_to_update = True 47 | else: 48 | previous = Review.objects.get(pk=self.pk) 49 | if previous.course_id != self.course_id or previous.rating != self.rating: 50 | need_to_update = True 51 | old_course = previous.course 52 | if previous.comment != self.comment: 53 | self.search_vector = get_cut_word_search_vector(self.comment) 54 | super().save(force_insert, force_update, using, update_fields) 55 | if need_to_update: 56 | update_course_reviews(self.course) 57 | if old_course and old_course != self.course: 58 | update_course_reviews(old_course) 59 | 60 | 61 | class ReviewRevision(models.Model): 62 | class Meta: 63 | verbose_name = '点评修订记录' 64 | verbose_name_plural = verbose_name 65 | 66 | review = models.ForeignKey(Review, verbose_name='点评', on_delete=models.SET_NULL, db_index=True, null=True) 67 | user = models.ForeignKey(User, verbose_name='执行用户', on_delete=models.SET_NULL, null=True) 68 | course = models.ForeignKey(Course, verbose_name='课程', on_delete=models.SET_NULL, null=True) 69 | semester = models.ForeignKey(Semester, verbose_name='上课学期', on_delete=models.SET_NULL, null=True) 70 | rating = models.IntegerField(verbose_name='推荐指数', validators=[MaxValueValidator(5), MinValueValidator(1)]) 71 | comment = models.TextField(verbose_name='详细点评', max_length=9681) 72 | created_at = models.DateTimeField(verbose_name='修订时间', default=timezone.now, db_index=True) 73 | score = models.CharField(verbose_name='成绩', null=True, blank=True, max_length=10) 74 | 75 | def comment_validity(self): 76 | return constrain_text(self.comment) 77 | 78 | comment_validity.short_description = '详细点评' 79 | 80 | 81 | class ReviewReaction(models.Model): 82 | class ReactionType(models.IntegerChoices): 83 | RESET = 0, '重置' 84 | APPROVE = 1, '赞同' 85 | DISAPPROVE = -1, '反对' 86 | 87 | class Meta: 88 | verbose_name = '点评回应' 89 | verbose_name_plural = verbose_name 90 | ordering = ['-modified_at'] 91 | constraints = [models.UniqueConstraint(fields=['user', 'review'], name='unique_reaction')] 92 | 93 | user = models.ForeignKey(User, verbose_name='用户', on_delete=models.CASCADE, db_index=True) 94 | review = models.ForeignKey(Review, verbose_name='点评', on_delete=models.CASCADE, db_index=True) 95 | reaction = models.IntegerField(choices=ReactionType.choices, verbose_name='操作', default=0, db_index=True) 96 | modified_at = models.DateTimeField(verbose_name='修改时间', blank=True, null=True, db_index=True, auto_now=True) 97 | 98 | def __str__(self): 99 | return f"{self.user} {self.get_reaction_display()} {self.review.id}" 100 | 101 | def save(self, force_insert=False, force_update=False, using=None, update_fields=None): 102 | need_to_update = False 103 | old_review = None 104 | if self.pk is None: 105 | need_to_update = True 106 | else: 107 | previous = ReviewReaction.objects.get(pk=self.pk) 108 | if previous.review_id != self.review_id or previous.reaction != self.reaction: 109 | need_to_update = True 110 | old_review = previous.review 111 | super().save(force_insert, force_update, using, update_fields) 112 | if need_to_update: 113 | update_review_reactions(self.review) 114 | if old_review and old_review != self.review: 115 | update_review_reactions(old_review) 116 | 117 | 118 | def update_review_reactions(review: Review): 119 | actions = ReviewReaction.objects.filter(review=review).aggregate(approves=Count('reaction', filter=Q(reaction=1)), 120 | disapproves=Count('reaction', 121 | filter=Q(reaction=-1))) 122 | review.approve_count = actions['approves'] 123 | review.disapprove_count = actions['disapproves'] 124 | review.save(update_fields=['approve_count', 'disapprove_count']) 125 | 126 | 127 | def update_course_reviews(course: Course): 128 | review = Review.objects.filter(course=course).aggregate(avg=Avg('rating'), count=Count('*')) 129 | course.review_count = review['count'] 130 | course.review_avg = review['avg'] 131 | course.save(update_fields=['review_count', 'review_avg']) 132 | -------------------------------------------------------------------------------- /jcourse_api/models/site.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | 5 | class Announcement(models.Model): 6 | class Meta: 7 | verbose_name = '公告' 8 | ordering = ['-created_at'] 9 | verbose_name_plural = verbose_name 10 | 11 | title = models.CharField(verbose_name='标题', max_length=256) 12 | message = models.TextField(verbose_name='正文', max_length=256) 13 | url = models.TextField(verbose_name='链接', max_length=256, null=True, blank=True) 14 | created_at = models.DateTimeField(verbose_name='发布时间', default=timezone.now) 15 | available = models.BooleanField(verbose_name='是否显示', default=True) 16 | 17 | def __str__(self): 18 | return self.title 19 | 20 | 21 | class ApiKey(models.Model): 22 | class Meta: 23 | verbose_name = 'Api密钥' 24 | verbose_name_plural = verbose_name 25 | 26 | key = models.CharField(max_length=255, unique=True, db_index=True) 27 | description = models.CharField(verbose_name='描述', max_length=255) 28 | is_enabled = models.BooleanField(verbose_name='启用', default=True) 29 | modified_at = models.DateTimeField(verbose_name='修改时间', default=timezone.now) 30 | 31 | def __str__(self): 32 | return f"{self.description}:{self.key} - {self.modified_at}" 33 | -------------------------------------------------------------------------------- /jcourse_api/models/user.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | from jcourse_api.models import Course, Semester, constrain_text 6 | 7 | 8 | class EnrollCourse(models.Model): 9 | class Meta: 10 | verbose_name = '选课记录' 11 | verbose_name_plural = verbose_name 12 | constraints = [models.UniqueConstraint(fields=['user', 'course', 'semester'], name='unique_enroll')] 13 | 14 | user = models.ForeignKey(User, verbose_name='用户', on_delete=models.CASCADE, db_index=True) 15 | course = models.ForeignKey(Course, on_delete=models.CASCADE, db_index=True) 16 | semester = models.ForeignKey(Semester, on_delete=models.SET_NULL, null=True, blank=True) 17 | created_at = models.DateTimeField(verbose_name='创建时间', default=timezone.now, db_index=True) 18 | 19 | def __str__(self): 20 | return f"{self.user} {self.course.name} {self.semester.name}" 21 | 22 | 23 | class UserPoint(models.Model): 24 | class Meta: 25 | verbose_name = '积分' 26 | verbose_name_plural = verbose_name 27 | 28 | user = models.ForeignKey(User, verbose_name='用户', on_delete=models.CASCADE, db_index=True) 29 | value = models.IntegerField(verbose_name='数值', default=0, null=False) 30 | description = models.CharField(verbose_name='原因', max_length=255, null=True) 31 | time = models.DateTimeField(verbose_name='时间', default=timezone.now, db_index=True) 32 | 33 | def __str__(self): 34 | return f"{self.user} 积分:{self.value} 原因:{constrain_text(self.description)}" 35 | 36 | 37 | class Report(models.Model): 38 | class Meta: 39 | verbose_name = '反馈' 40 | ordering = ['-created_at'] 41 | verbose_name_plural = verbose_name 42 | 43 | user = models.ForeignKey(User, verbose_name='用户', on_delete=models.CASCADE, db_index=True) 44 | solved = models.BooleanField(verbose_name='是否解决', default=False, db_index=True) 45 | comment = models.TextField(verbose_name='反馈', max_length=817) 46 | created_at = models.DateTimeField(verbose_name='发布时间', default=timezone.now, db_index=True) 47 | reply = models.TextField(verbose_name='回复', max_length=817, null=True, blank=True) 48 | 49 | def __str__(self): 50 | return f"{self.user}:{constrain_text(self.comment)}" 51 | 52 | def comment_validity(self): 53 | return constrain_text(self.comment) 54 | 55 | def reply_validity(self): 56 | return constrain_text(self.reply) 57 | 58 | comment_validity.short_description = '反馈' 59 | reply_validity.short_description = '回复' 60 | -------------------------------------------------------------------------------- /jcourse_api/permissions.py: -------------------------------------------------------------------------------- 1 | from jcourse import settings 2 | from rest_framework.permissions import SAFE_METHODS, BasePermission 3 | 4 | 5 | class CustomBasePermission(BasePermission): 6 | 7 | def has_permission(self, request, view): 8 | if settings.BENCHMARK: 9 | return True 10 | return bool(request.user and request.user.is_authenticated) 11 | 12 | 13 | class IsOwnerOrAdminOrReadOnly(CustomBasePermission): 14 | message = '您只能修改自己发表的内容。' 15 | 16 | def has_object_permission(self, request, view, obj): 17 | if self.has_permission(request, view): 18 | if request.method in SAFE_METHODS: 19 | return True 20 | return obj.user == request.user or request.user.is_staff 21 | else: 22 | return False 23 | 24 | 25 | class IsAdminOrReadOnly(CustomBasePermission): 26 | message = '当前仅管理员可以发表和修改内容。' 27 | 28 | def has_permission(self, request, view): 29 | if request.user: 30 | if request.method in SAFE_METHODS: 31 | return True 32 | return request.user.is_staff 33 | else: 34 | return False 35 | -------------------------------------------------------------------------------- /jcourse_api/repository/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Subquery, OuterRef 2 | 3 | from jcourse_api.models import * 4 | 5 | 6 | def get_semesters(): 7 | return Semester.objects.all() 8 | 9 | 10 | def get_announcements(): 11 | return Announcement.objects.filter(available=True) 12 | 13 | 14 | def get_course_list_queryset(user: User): 15 | return Course.objects.select_related('main_teacher').prefetch_related('categories', 'department') 16 | 17 | 18 | def get_search_course_queryset(q: str, user: User): 19 | courses = get_course_list_queryset(user) 20 | if q == '': 21 | return courses.none() 22 | courses = courses.filter( 23 | Q(code__icontains=q) | Q(name__icontains=q) | Q(main_teacher__name__icontains=q) | 24 | Q(main_teacher__pinyin__iexact=q) | Q(main_teacher__abbr_pinyin__icontains=q)) 25 | return courses 26 | 27 | 28 | def get_reviews(user: User): 29 | reviews = Review.objects.select_related('course', 'course__main_teacher', 'semester') 30 | if not user.is_authenticated: 31 | return reviews 32 | my_reaction = ReviewReaction.objects.filter(user=user, review_id=OuterRef('pk')).values('reaction') 33 | return reviews.annotate(my_reaction=Subquery(my_reaction[:1])) 34 | 35 | 36 | def get_enrolled_courses(user: User): 37 | return EnrollCourse.objects.filter(user=user).values('semester_id', 'course_id') 38 | 39 | 40 | def get_my_reviewed(user: User): 41 | return Review.objects.filter(user=user).values('course_id', 'semester_id', 'id') 42 | -------------------------------------------------------------------------------- /jcourse_api/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .course import * 3 | from .review import * 4 | -------------------------------------------------------------------------------- /jcourse_api/serializers/base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from rest_framework import serializers 3 | 4 | from jcourse_api.models import Teacher, Semester, Announcement, Report, Notification, Category, Department, UserPoint 5 | 6 | 7 | class TeacherSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = Teacher 10 | fields = ('tid', 'name') 11 | 12 | 13 | class SemesterSerializer(serializers.ModelSerializer): 14 | class Meta: 15 | model = Semester 16 | fields = ('id', 'name', 'available') 17 | 18 | 19 | class UserSerializer(serializers.ModelSerializer): 20 | class Meta: 21 | model = User 22 | fields = ('id', 'username', 'is_staff') 23 | 24 | 25 | class AnnouncementSerializer(serializers.ModelSerializer): 26 | class Meta: 27 | model = Announcement 28 | fields = ('title', 'message', 'created_at', 'url') 29 | 30 | 31 | class ReportSerializer(serializers.ModelSerializer): 32 | class Meta: 33 | model = Report 34 | exclude = ('solved',) 35 | read_only_fields = ('user', 'created_at', 'reply') 36 | 37 | 38 | class NotificationSerializer(serializers.ModelSerializer): 39 | class Meta: 40 | model = Notification 41 | fields = ('id', 'recipient', 'type', 'description', 'created_at', 'read_at') 42 | 43 | 44 | class CategorySerializer(serializers.ModelSerializer): 45 | class Meta: 46 | model = Category 47 | fields = '__all__' 48 | 49 | count = serializers.SerializerMethodField() 50 | 51 | @staticmethod 52 | def get_count(obj): 53 | return obj.count 54 | 55 | 56 | class DepartmentSerializer(serializers.ModelSerializer): 57 | class Meta: 58 | model = Department 59 | fields = '__all__' 60 | 61 | count = serializers.SerializerMethodField() 62 | 63 | @staticmethod 64 | def get_count(obj): 65 | return obj.count 66 | 67 | 68 | class UserPointSerializer(serializers.ModelSerializer): 69 | class Meta: 70 | model = UserPoint 71 | exclude = ('user', 'id') 72 | -------------------------------------------------------------------------------- /jcourse_api/serializers/course.py: -------------------------------------------------------------------------------- 1 | from django.db.models import F 2 | from rest_framework import serializers 3 | 4 | from jcourse_api.models import Course, Department, Category, CourseNotificationLevel 5 | from jcourse_api.serializers.base import TeacherSerializer 6 | 7 | 8 | def get_course_rating(obj: Course): 9 | return {'count': obj.review_count, 'avg': obj.review_avg} 10 | 11 | 12 | class CourseSerializer(serializers.ModelSerializer): 13 | categories = serializers.SlugRelatedField( 14 | queryset=Category.objects.all(), 15 | many=True, 16 | required=False, 17 | slug_field='name' 18 | ) 19 | department = serializers.SlugRelatedField( 20 | queryset=Department.objects.all(), 21 | many=False, 22 | required=False, 23 | slug_field='name' 24 | ) 25 | main_teacher = TeacherSerializer(read_only=True) 26 | teacher_group = TeacherSerializer(many=True, read_only=True) 27 | rating = serializers.SerializerMethodField() 28 | related_teachers = serializers.SerializerMethodField() 29 | related_courses = serializers.SerializerMethodField() 30 | notification_level = serializers.SerializerMethodField() 31 | 32 | class Meta: 33 | model = Course 34 | exclude = ['review_count', 'review_avg', 'last_semester'] 35 | 36 | @staticmethod 37 | def get_rating(obj: Course): 38 | return get_course_rating(obj) 39 | 40 | @staticmethod 41 | def get_related_teachers(obj: Course): 42 | return Course.objects.filter(code=obj.code).exclude(main_teacher=obj.main_teacher) \ 43 | .values('id', avg=F('review_avg'), count=F('review_count'), 44 | tname=F('main_teacher__name')).order_by(F('avg').desc(nulls_last=True), 45 | F('count').desc(nulls_last=True)) 46 | 47 | @staticmethod 48 | def get_related_courses(obj: Course): 49 | return Course.objects.filter(main_teacher=obj.main_teacher).exclude(code=obj.code) \ 50 | .values('id', 'code', 'name', avg=F('review_avg'), 51 | count=F('review_count')).order_by(F('avg').desc(nulls_last=True), F('count').desc(nulls_last=True)) 52 | 53 | def get_notification_level(self, obj): 54 | request = self.context.get("request") 55 | if request and hasattr(request, "user"): 56 | try: 57 | return CourseNotificationLevel.objects.get(user=request.user, course_id=obj.id).notification_level 58 | except CourseNotificationLevel.DoesNotExist: 59 | return None 60 | return None 61 | 62 | 63 | class CourseListSerializer(serializers.ModelSerializer): 64 | categories = serializers.SlugRelatedField( 65 | queryset=Category.objects.all(), 66 | many=True, 67 | required=False, 68 | slug_field='name' 69 | ) 70 | department = serializers.SlugRelatedField( 71 | queryset=Department.objects.all(), 72 | many=False, 73 | required=False, 74 | slug_field='name' 75 | ) 76 | teacher = serializers.SerializerMethodField() 77 | rating = serializers.SerializerMethodField() 78 | 79 | class Meta: 80 | model = Course 81 | exclude = ['teacher_group', 'main_teacher', 'moderator_remark', 'review_count', 'review_avg', 'last_semester'] 82 | 83 | @staticmethod 84 | def get_rating(obj: Course): 85 | return get_course_rating(obj) 86 | 87 | @staticmethod 88 | def get_teacher(obj: Course): 89 | return obj.main_teacher.name 90 | 91 | 92 | class CourseInReviewListSerializer(serializers.ModelSerializer): 93 | teacher = serializers.SerializerMethodField() 94 | 95 | class Meta: 96 | model = Course 97 | fields = ['id', 'code', 'name', 'teacher'] 98 | 99 | @staticmethod 100 | def get_teacher(obj: Course): 101 | return obj.main_teacher.name 102 | 103 | 104 | class CourseInWriteReviewSerializer(serializers.ModelSerializer): 105 | teacher = serializers.SerializerMethodField() 106 | 107 | class Meta: 108 | model = Course 109 | fields = ['id', 'code', 'name', 'teacher'] 110 | 111 | @staticmethod 112 | def get_teacher(obj: Course): 113 | return obj.main_teacher.name 114 | -------------------------------------------------------------------------------- /jcourse_api/serializers/review.py: -------------------------------------------------------------------------------- 1 | from django.db import IntegrityError 2 | from rest_framework import serializers 3 | 4 | from jcourse_api.models import Review, ReviewRevision 5 | from jcourse_api.serializers.base import SemesterSerializer 6 | from jcourse_api.serializers.course import CourseInReviewListSerializer, CourseInWriteReviewSerializer 7 | 8 | 9 | class CreateReviewSerializer(serializers.ModelSerializer): 10 | class Meta: 11 | model = Review 12 | exclude = ['moderator_remark', 'approve_count', 'disapprove_count', 'search_vector'] 13 | read_only_fields = ['user', 'created_at', 'modified_at'] 14 | 15 | def create(self, validated_data): 16 | try: 17 | return super().create(validated_data) 18 | except IntegrityError: 19 | error_msg = {'error': '已经点评过这门课,如需修改,请从“修改点评”入口进入'} 20 | raise serializers.ValidationError(error_msg) 21 | 22 | 23 | def get_review_reactions(obj): 24 | reactions = {'approves': obj.approve_count, 'disapproves': obj.disapprove_count} 25 | if hasattr(obj, "my_reaction"): 26 | reactions["reaction"] = obj.my_reaction 27 | return reactions 28 | 29 | 30 | def is_my_review(serializer: serializers.Serializer, obj: Review): 31 | request = serializer.context.get("request") 32 | if request and hasattr(request, "user"): 33 | user = request.user 34 | return obj.user_id == user.id 35 | return False 36 | 37 | 38 | class ReviewCommonSerializer(serializers.ModelSerializer): 39 | reactions = serializers.SerializerMethodField() 40 | is_mine = serializers.SerializerMethodField() 41 | semester = serializers.SerializerMethodField() 42 | 43 | class Meta: 44 | model = Review 45 | 46 | @staticmethod 47 | def get_semester(obj): 48 | return obj.semester.name 49 | 50 | def get_is_mine(self, obj: Review): 51 | return is_my_review(self, obj) 52 | 53 | @staticmethod 54 | def get_reactions(obj): 55 | return get_review_reactions(obj) 56 | 57 | 58 | class ReviewListSerializer(ReviewCommonSerializer): 59 | course = CourseInReviewListSerializer(read_only=True) 60 | 61 | class Meta: 62 | model = Review 63 | exclude = ['user', 'approve_count', 'disapprove_count', 'search_vector'] 64 | 65 | 66 | class ReviewItemSerializer(ReviewCommonSerializer): 67 | course = serializers.SerializerMethodField() 68 | semester = SemesterSerializer() 69 | 70 | class Meta: 71 | model = Review 72 | exclude = ['user', 'approve_count', 'disapprove_count', 'search_vector'] 73 | 74 | @staticmethod 75 | def get_course(obj): 76 | serializer = CourseInWriteReviewSerializer(obj.course) 77 | return serializer.data 78 | 79 | 80 | class ReviewInCourseSerializer(ReviewCommonSerializer): 81 | class Meta: 82 | model = Review 83 | exclude = ('user', 'course', 'approve_count', 'disapprove_count', 'search_vector') 84 | 85 | 86 | class ReviewRevisionSerializer(serializers.ModelSerializer): 87 | semester = serializers.SerializerMethodField() 88 | course = CourseInWriteReviewSerializer() 89 | 90 | @staticmethod 91 | def get_semester(obj): 92 | return obj.semester.name 93 | 94 | class Meta: 95 | model = ReviewRevision 96 | exclude = ('review', 'user') 97 | -------------------------------------------------------------------------------- /jcourse_api/signals.py: -------------------------------------------------------------------------------- 1 | from jcourse_api.models import * 2 | 3 | 4 | def signal_delete_review_actions(sender, instance: ReviewReaction, **kwargs): 5 | update_review_reactions(instance.review) 6 | 7 | 8 | def signal_delete_course_reviews(sender, instance: Review, **kwargs): 9 | update_course_reviews(instance.course) 10 | 11 | 12 | def signal_notify_report_replied(sender, instance: Report, **kwargs): 13 | send_report_replied_notification(instance) 14 | 15 | 16 | def signal_notify_new_review_generated(sender, instance: Review, **kwargs): 17 | find_course_new_review(instance.course) 18 | -------------------------------------------------------------------------------- /jcourse_api/tasks.py: -------------------------------------------------------------------------------- 1 | from huey.contrib.djhuey import task 2 | 3 | from jcourse_api.utils import send_admin_email 4 | 5 | 6 | @task() 7 | def send_report_email(comment: str, time: str): 8 | send_admin_email('选课社区反馈', f"内容:\n{comment}\n时间:{time}") 9 | 10 | 11 | @task() 12 | def send_antispam_email(username: str, data: dict): 13 | send_admin_email('选课社区风控', f"用户:{username} 由于刷点评,已被自动封号。最近点评为:\n{data}") 14 | -------------------------------------------------------------------------------- /jcourse_api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from jcourse_api.models import * 2 | 3 | 4 | def create_test_env() -> None: 5 | dept_seiee = Department.objects.create(name='SEIEE') 6 | dept_phy = Department.objects.create(name='PHYSICS') 7 | teacher_gao = Teacher.objects.create(tid=1, name='高女士', department=dept_seiee, title='教授', 8 | pinyin='gaoxiaofeng', 9 | abbr_pinyin='gxf') 10 | teacher_pan = Teacher.objects.create(tid=4, name='潘老师', department=dept_seiee, title='教授', pinyin='panli', 11 | abbr_pinyin='pl') 12 | teacher_liang = Teacher.objects.create(tid=2, name='梁女士', department=dept_phy, pinyin='liangqin', 13 | abbr_pinyin='lq') 14 | teacher_zhao = Teacher.objects.create(tid=3, name='赵先生', department=dept_phy, title='讲师', pinyin='zhaohao', 15 | abbr_pinyin='zh') 16 | category = Category.objects.create(name='通识') 17 | c1 = Course.objects.create(code='CS2500', name='算法与复杂性', credit=2, department=dept_seiee, 18 | main_teacher=teacher_gao) 19 | c1.teacher_group.add(teacher_gao) 20 | c2 = Course.objects.create(code='CS1500', name='计算机科学导论', credit=4, department=dept_seiee, 21 | main_teacher=teacher_gao) 22 | c2.teacher_group.add(teacher_gao) 23 | c2.teacher_group.add(teacher_pan) 24 | c3 = Course.objects.create(code='MARX1001', name='思想道德修养与法律基础', credit=3, department=dept_phy, 25 | main_teacher=teacher_liang) 26 | c3.categories.add(category) 27 | c3.teacher_group.add(teacher_liang) 28 | c4 = Course.objects.create(code='MARX1001', name='思想道德修养与法律基础', credit=3, department=dept_phy, 29 | main_teacher=teacher_zhao) 30 | c4.teacher_group.add(teacher_zhao) 31 | c4.categories.add(category) 32 | 33 | Semester.objects.create(name='2021-2022-1') 34 | Semester.objects.create(name='2021-2022-2') 35 | Semester.objects.create(name='2021-2022-3') 36 | Semester.objects.create(name='2022-2023-1', available=False) 37 | User.objects.create(username='test') 38 | 39 | 40 | def create_review(username: str = 'test', code: str = 'CS1500', rating: int = 3) -> Review: 41 | user, _ = User.objects.get_or_create(username=username) 42 | course = Course.objects.filter(code=code).first() 43 | now = timezone.now() 44 | review = Review.objects.create(user=user, course=course, comment='TEST', rating=rating, score='W', 45 | semester=Semester.objects.get(name='2021-2022-1'), 46 | created_at=now, modified_at=now) 47 | ReviewReaction.objects.create(review=review, user=user, reaction=1) 48 | return review 49 | -------------------------------------------------------------------------------- /jcourse_api/tests/test_course_notification_level.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.test import APIClient 3 | 4 | from jcourse_api.models import * 5 | from jcourse_api.tests import create_test_env 6 | 7 | 8 | class CourseNotificationLevelTest(TestCase): 9 | def setUp(self) -> None: 10 | self.user1 = User.objects.create(username='test1') 11 | self.user2 = User.objects.create(username='test2') 12 | self.user3 = User.objects.create(username='test3') 13 | 14 | self.client1 = APIClient() 15 | self.user = User.objects.get(username='test1') 16 | self.client1.force_login(self.user) 17 | 18 | self.client2 = APIClient() 19 | self.client2.force_login(self.user2) 20 | 21 | create_test_env() 22 | 23 | dept_seiee = Department.objects.get(name='SEIEE') 24 | teacher_zhang = Teacher.objects.create(tid=5, name='张先生', department=dept_seiee, title='教授', 25 | pinyin='zhangfeng', abbr_pinyin='zf') 26 | teacher_liang = Teacher.objects.get(tid=2) 27 | 28 | self.course1 = Course.objects.get(code='CS2500') 29 | self.course2 = Course.objects.get(code='CS1500') 30 | self.course3 = Course.objects.get(code='MARX1001', main_teacher=teacher_liang) 31 | self.course4 = Course.objects.create(code='EE0501', name='电路理论', credit=4, department=dept_seiee, 32 | main_teacher=teacher_zhang) 33 | self.course5 = Course.objects.create(code='EE0502', name='电路实验', credit=2, department=dept_seiee, 34 | main_teacher=teacher_zhang) 35 | 36 | CourseNotificationLevel.objects.create( 37 | user=self.user, 38 | course=self.course1, 39 | notification_level=CourseNotificationLevel.NotificationLevelType.FOLLOW, 40 | modified_at=timezone.now() - timezone.timedelta(days=3) 41 | ) 42 | CourseNotificationLevel.objects.create( 43 | user=self.user, 44 | course=self.course2, 45 | notification_level=CourseNotificationLevel.NotificationLevelType.IGNORE, 46 | modified_at=timezone.now() - timezone.timedelta(days=2) 47 | ) 48 | CourseNotificationLevel.objects.create( 49 | user=self.user, 50 | course=self.course4, 51 | notification_level=CourseNotificationLevel.NotificationLevelType.FOLLOW, 52 | modified_at=timezone.now() - timezone.timedelta(days=1) 53 | ) 54 | CourseNotificationLevel.objects.create( 55 | user=self.user, 56 | course=self.course5, 57 | notification_level=CourseNotificationLevel.NotificationLevelType.FOLLOW, 58 | modified_at=timezone.now() 59 | ) 60 | 61 | Review.objects.create(user=self.user, course=self.course1, comment='TEST', rating=3, score='W', 62 | semester=Semester.objects.get(name='2021-2022-1')) 63 | Review.objects.create(user=self.user, course=self.course2, comment='TEST', rating=3, score='W', 64 | semester=Semester.objects.get(name='2021-2022-2')) 65 | Review.objects.create(user=self.user, course=self.course4, comment='TEST', rating=3, score='W', 66 | semester=Semester.objects.get(name='2021-2022-2')) 67 | Review.objects.create(user=self.user, course=self.course5, comment='TEST', rating=3, score='W', 68 | semester=Semester.objects.get(name='2021-2022-2')) 69 | 70 | Review.objects.create(user=self.user2, course=self.course1, comment='TEST', rating=3, score='W', 71 | semester=Semester.objects.get(name='2021-2022-2')) 72 | Review.objects.create(user=self.user2, course=self.course4, comment='TEST', rating=3, score='W', 73 | semester=Semester.objects.get(name='2021-2022-1')) 74 | 75 | def test_course_notification_level(self): 76 | my_course_notification_level = CourseNotificationLevel.objects.create( 77 | user=self.user, 78 | course=self.course3, 79 | notification_level=CourseNotificationLevel.NotificationLevelType.FOLLOW, 80 | ) 81 | self.assertEqual(my_course_notification_level.user, self.user) 82 | self.assertEqual(my_course_notification_level.course, self.course3) 83 | self.assertEqual(my_course_notification_level.notification_level, 84 | CourseNotificationLevel.NotificationLevelType.FOLLOW) 85 | 86 | # def test_notify_new_review_generated(self): 87 | # count = Notification.objects.count() 88 | # CourseNotificationLevel.objects.create( 89 | # user=self.user, 90 | # course=self.course3, 91 | # notification_level=CourseNotificationLevel.NotificationLevelType.FOLLOW, 92 | # ) 93 | # Review.objects.create(user=self.user, course=self.course3, comment='TEST', rating=3, score='W', 94 | # semester=Semester.objects.get(name='2021-2022-1')) 95 | # self.assertEqual(Notification.objects.count(), count + 1) 96 | 97 | def test_course_follow_list(self): 98 | response = self.client1.get('/api/course/?notification_level=1').json() 99 | self.assertEqual(int(response['count']), 3) 100 | response = self.client1.get('/api/course/?notification_level=2').json() 101 | self.assertEqual(int(response['count']), 1) 102 | response = self.client1.get('/api/course/').json() 103 | self.assertEqual(int(response['count']), 6) 104 | response = self.client1.get('/api/course/?notification_level=6') 105 | self.assertEqual(response.status_code, 200) 106 | response = self.client2.get('/api/course/?notification_level=1').json() 107 | self.assertEqual(int(response['count']), 0) 108 | 109 | def test_show_course_notification_level(self): 110 | response = self.client1.get(f'/api/course/{self.course1.id}/').json() 111 | self.assertEqual(response['notification_level'], 1) 112 | response = self.client1.get(f'/api/course/{self.course3.id}/').json() 113 | self.assertEqual(response['notification_level'], None) 114 | 115 | def test_change_notification_level(self): 116 | response = self.client1.post(f'/api/course/{self.course1.id}/notification_level/', {'level': '0'}).json() 117 | self.assertEqual(response['notification_level'], 0) 118 | response = self.client1.post(f'/api/course/{self.course1.id}/notification_level/', {'level': '2'}).json() 119 | self.assertEqual(response['notification_level'], 2) 120 | response = self.client1.post(f'/api/course/{self.course1.id}/notification_level/', {'level': '4'}).json() 121 | self.assertEqual(response, {'error': '无效的操作类型!'}) 122 | response = self.client1.post(f'/api/course/{self.course1.id}/notification_level/', {'read': '4'}).json() 123 | self.assertEqual(response, {'error': '未指定操作类型!'}) 124 | response = self.client1.post(f'/api/course/11111/notification_level/', {'level': '0'}) 125 | self.assertEqual(response.status_code, 404) 126 | 127 | def test_review_follow_list(self): 128 | response = self.client1.get('/api/review/?notification_level=1').json() 129 | self.assertEqual(int(response['count']), 5) 130 | response = self.client1.get('/api/review/?notification_level=2').json() 131 | self.assertEqual(int(response['count']), 1) 132 | response = self.client1.get('/api/review/').json() 133 | self.assertEqual(int(response['count']), 5) 134 | response = self.client1.get('/api/review/?notification_level=6') 135 | self.assertEqual(response.status_code, 200) 136 | 137 | response = self.client2.get('/api/review/?notification_level=1').json() 138 | self.assertEqual(int(response['count']), 0) 139 | -------------------------------------------------------------------------------- /jcourse_api/tests/test_misc.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.test import TestCase 4 | from rest_framework.test import APIClient 5 | 6 | from jcourse_api.tests import * 7 | from oauth.utils import hash_username 8 | 9 | 10 | class AnnouncementTest(TestCase): 11 | def setUp(self) -> None: 12 | self.client = APIClient() 13 | self.user = User.objects.create(username='test') 14 | self.client.force_login(self.user) 15 | Announcement.objects.create(title='TEST3', message='Just a test notice', 16 | created_at=timezone.now() - datetime.timedelta(days=1), 17 | available=True) 18 | Announcement.objects.create(title='TEST1', message='Just a test notice', 19 | created_at=timezone.now(), 20 | available=True, 21 | url='https://example.com') 22 | Announcement.objects.create(title='TEST2', message='Just a test notice', available=False) 23 | 24 | def test_list(self): 25 | response = self.client.get('/api/announcement/').json() 26 | self.assertEqual(len(response), 2) 27 | self.assertEqual(response[0]['title'], 'TEST1') 28 | self.assertEqual(response[0]['message'], 'Just a test notice') 29 | self.assertEqual(response[0]['url'], 'https://example.com') 30 | self.assertEqual(response[1]['url'], None) 31 | self.assertEqual(response[1]['title'], 'TEST3') 32 | 33 | 34 | class StatisticTest(TestCase): 35 | def setUp(self) -> None: 36 | create_test_env() 37 | create_review() 38 | self.client = APIClient() 39 | self.user = User.objects.get(username='test') 40 | self.client.force_login(self.user) 41 | 42 | def test_get(self): 43 | response = self.client.get('/api/statistic/').json() 44 | self.assertEqual(response['course_count'], 4) 45 | self.assertEqual(response['user_count'], 1) 46 | self.assertEqual(response['review_count'], 1) 47 | self.assertEqual(response['course_with_review_count'], 1) 48 | self.assertEqual(response['course_review_count_dist'], [{"value": 1, "count": 1}]) 49 | self.assertEqual(response['course_review_avg_dist'], [{"value": 3.0, "count": 1}]) 50 | self.assertEqual(response['review_rating_dist'], [{"value": 3, "count": 1}]) 51 | self.assertIsNotNone(response['user_join_time']) 52 | self.assertIsNotNone(response['review_create_time']) 53 | 54 | 55 | class ApiKeyTest(TestCase): 56 | def setUp(self) -> None: 57 | create_test_env() 58 | create_review() 59 | self.client = APIClient() 60 | self.endpoint = '/api/points/' 61 | user = User.objects.get(username='test') 62 | user.username = hash_username('test') 63 | user.save() 64 | self.apikey = ApiKey.objects.create(key='123456', description='TEST') 65 | 66 | def test_normal(self): 67 | data = {'account': 'test'} 68 | response = self.client.post(self.endpoint, data, HTTP_API_KEY="123456").json() 69 | self.assertEqual(response['points'], 0) 70 | 71 | def test_wrong_apikey(self): 72 | data = {'account': 'test'} 73 | response = self.client.post(self.endpoint, data, HTTP_API_KEY="123457") 74 | self.assertEqual(response.status_code, 400) 75 | 76 | def test_wrong_account(self): 77 | data = {'account': 'test2'} 78 | response = self.client.post(self.endpoint, data, HTTP_API_KEY="123457") 79 | self.assertEqual(response.status_code, 400) 80 | 81 | def test_blocked_user(self): 82 | user = User.objects.get(username=hash_username('test')) 83 | user.is_active = False 84 | user.save() 85 | data = {'account': 'test'} 86 | response = self.client.post(self.endpoint, data, HTTP_API_KEY="123457") 87 | self.assertEqual(response.status_code, 400) 88 | 89 | 90 | class CommonInfoTestCase(TestCase): 91 | 92 | def setUp(self) -> None: 93 | create_test_env() 94 | create_review() 95 | self.client = APIClient() 96 | self.endpoint = '/api/common/' 97 | self.user = User.objects.get(username='test') 98 | self.client.force_login(self.user) 99 | 100 | self.announcement = Announcement.objects.create(title='TEST3', message='Just a test notice', 101 | created_at=timezone.now(), 102 | available=True) 103 | self.semester = Semester.objects.first() 104 | self.course = Course.objects.first() 105 | self.enroll = EnrollCourse.objects.create(course=self.course, semester=self.semester, user=self.user) 106 | self.review = Review.objects.get(user=self.user) 107 | 108 | def test_request_body(self): 109 | resp = self.client.get(self.endpoint).json() 110 | self.assertEqual(resp["user"], 111 | {"id": self.user.id, "username": self.user.username, "is_staff": self.user.is_staff}) 112 | self.assertEqual(len(resp["announcements"]), 1) 113 | self.assertEqual(len(resp["semesters"]), 4) 114 | self.assertEqual(len(resp["enrolled_courses"]), 1) 115 | self.assertEqual(resp["enrolled_courses"][0], {"semester_id": self.semester.id, "course_id": self.course.id}) 116 | self.assertEqual(resp["my_reviews"][0], 117 | {"semester_id": self.review.semester_id, "course_id": self.review.course_id, 118 | "id": self.review.id}) 119 | -------------------------------------------------------------------------------- /jcourse_api/tests/test_notification.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.test import TestCase 4 | from rest_framework.test import APIClient 5 | 6 | from jcourse_api.models import * 7 | 8 | 9 | class NotificationTest(TestCase): 10 | def create_env(self): 11 | report = Report.objects.create(user=self.user, comment='test', created_at=timezone.now()) 12 | self.notification1 = Notification.objects.create(recipient=self.user2, 13 | type=7, 14 | content_type=ContentType.objects.get_for_model(report), 15 | object_id=report.id, 16 | created_at=timezone.now() - datetime.timedelta(days=1) 17 | ) 18 | self.notification2 = Notification.objects.create(recipient=self.user, 19 | type=1, 20 | created_at=timezone.now() - datetime.timedelta(days=2) 21 | ) 22 | self.notification3 = Notification.objects.create(recipient=self.user, 23 | type=1, 24 | public=False, 25 | read_at=timezone.now() - datetime.timedelta(hours=3), 26 | created_at=timezone.now() - datetime.timedelta(hours=4) 27 | ) 28 | self.notification4 = Notification.objects.create(recipient=self.user, 29 | type=2, 30 | content_type=ContentType.objects.get_for_model(report), 31 | object_id=report.id, 32 | read_at=timezone.now(), 33 | created_at=timezone.now() - datetime.timedelta(hours=2) 34 | ) 35 | 36 | def setUp(self) -> None: 37 | self.client = APIClient() 38 | self.user = User.objects.create(username='test4') 39 | self.user2 = User.objects.create(username='test5') 40 | self.client.force_login(self.user) 41 | self.endpoint = '/api/notification/' 42 | 43 | def test_report_replied_notification(self): 44 | """ 45 | Test if notification is created_at when a report is replied. 46 | """ 47 | from jcourse_api.models import Report 48 | report = Report.objects.create(user=self.user, comment='test', created_at=timezone.now()) 49 | self.assertEqual(Notification.objects.count(), 0) 50 | report.reply = 'TEST' 51 | report.save() 52 | self.assertEqual(Notification.objects.count(), 1) 53 | notification = Notification.objects.first() 54 | self.assertEqual(notification.recipient, self.user) 55 | self.assertEqual(notification.type, 7) 56 | self.assertEqual(notification.read_at, None) 57 | 58 | def test_user_notification_list_empty(self): 59 | response = self.client.get(self.endpoint).json() 60 | self.assertEqual(len(response['results']), 0) 61 | 62 | def test_not_authed_user_notification_list(self): 63 | self.create_env() 64 | client = APIClient() 65 | user8 = User.objects.create(username='test8') 66 | client.force_login(user8) 67 | response = client.get(self.endpoint).json() 68 | self.assertEqual(len(response['results']), 0) 69 | 70 | def test_user_notification_list(self): 71 | self.create_env() 72 | response = self.client.get(self.endpoint).json() 73 | self.assertEqual(len(response['results']), 2) 74 | 75 | def test_user_change_notification_read_or_unread(self): 76 | self.create_env() 77 | response1 = self.client.post(f'{self.endpoint}{self.notification2.id}/read/', {'read': '1'}).json() 78 | self.assertIsNotNone(response1['read_at']) 79 | notification1 = Notification.objects.get(id=self.notification2.id) 80 | self.assertIsNotNone(notification1.read_at) 81 | response2 = self.client.post(f'{self.endpoint}{self.notification2.id}/read/', {'read': '0'}).json() 82 | self.assertIsNone(response2['read_at']) 83 | notification2 = Notification.objects.get(id=self.notification2.id) 84 | self.assertIsNone(notification2.read_at) 85 | response = self.client.post(f'{self.endpoint}{1111}/read/', {'read': '0'}) 86 | self.assertEqual(response.status_code, 404) 87 | response = self.client.post(f'{self.endpoint}{self.notification2.id}/read/', {'find': '0'}).json() 88 | self.assertEqual(response, {'error': '未指定操作类型!'}) 89 | 90 | def test_not_authed_user_change_read_state(self): 91 | self.create_env() 92 | client = APIClient() 93 | user8 = User.objects.create(username='test8') 94 | client.force_login(user8) 95 | response = client.post(f'{self.endpoint}{self.notification2.id}/read/', {'read': '1'}) 96 | self.assertEqual(response.status_code, 404) 97 | -------------------------------------------------------------------------------- /jcourse_api/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.test import TestCase 4 | 5 | from jcourse_api.tests import create_test_env, create_review 6 | from jcourse_api.utils import * 7 | 8 | 9 | class MergeCourseTest(TestCase): 10 | def setUp(self) -> None: 11 | create_test_env() 12 | self.user = User.objects.get(username='test') 13 | self.old_review = create_review('test', 'CS1500', 3) 14 | self.old_course = Course.objects.get(code='CS1500') 15 | self.old_teacher = Teacher.objects.get(tid=1, name='高女士') 16 | self.semester = Semester.objects.get(name='2021-2022-1') 17 | self.enroll = EnrollCourse.objects.create(user=self.user, course=self.old_course, semester=self.semester) 18 | 19 | def test_merge_course(self): 20 | user2 = User.objects.create(username="test2") 21 | new_course = Course.objects.create(code='NEW1500', main_teacher=self.old_teacher) 22 | enroll2 = EnrollCourse.objects.create(user=user2, course=new_course, semester=self.semester) 23 | enroll3 = EnrollCourse.objects.create(user=user2, course=self.old_course, semester=self.semester) 24 | merge_course(self.old_course, new_course) 25 | new_course = Course.objects.get(code='NEW1500', main_teacher=self.old_teacher) 26 | self.assertFalse(Course.objects.filter(code='CS1500').exists()) 27 | self.assertEqual(Review.objects.get(pk=self.old_review.pk).course_id, new_course.pk) 28 | self.assertEqual(new_course.review_count, 1) 29 | self.assertEqual(new_course.review_avg, 3) 30 | # 旧的被修改为新的 31 | self.assertEqual(EnrollCourse.objects.get(pk=self.enroll.pk).course_id, new_course.pk) 32 | # 重复的被删掉 33 | self.assertFalse(EnrollCourse.objects.filter(pk=enroll3.pk).exists()) 34 | # 新的不变 35 | self.assertEqual(EnrollCourse.objects.get(pk=enroll2.pk).course, new_course) 36 | 37 | def test_merge_course_by_id(self): 38 | self.assertEqual(merge_course_by_id(10, 20), False) 39 | new_course = Course.objects.create(code='NEW1500', main_teacher=self.old_teacher) 40 | self.assertEqual(merge_course_by_id(self.old_course.pk, new_course.pk), True) 41 | 42 | def test_merge_teacher_move(self): 43 | new_teacher = Teacher.objects.create(name='高女士2') 44 | merge_teacher(self.old_teacher, new_teacher) 45 | self.assertEqual(Course.objects.get(code='CS1500').main_teacher, new_teacher) 46 | 47 | def test_merge_teacher_merge(self): 48 | old_teacher = Teacher.objects.get(tid=2, name='梁女士') 49 | new_teacher = Teacher.objects.get(tid=3, name='赵先生') 50 | create_review('test', 'MARX1001', 3) 51 | merge_teacher(old_teacher, new_teacher) 52 | self.assertEqual(Course.objects.filter(code='MARX1001').count(), 1) 53 | course = Course.objects.get(code='MARX1001') 54 | self.assertEqual(course.review_count, 1) 55 | self.assertEqual(course.review_avg, 3) 56 | 57 | def test_merge_teacher_by_id(self): 58 | self.assertEqual(merge_teacher_by_id(10, 20), False) 59 | old_teacher = Teacher.objects.get(tid=2, name='梁女士') 60 | new_teacher = Teacher.objects.get(tid=3, name='赵先生') 61 | self.assertEqual(merge_teacher_by_id(old_teacher.pk, new_teacher.pk), True) 62 | 63 | def test_replace_code_move(self): 64 | replace_course_code_multi('CS1500', 'NEW1500') 65 | self.assertEqual(Course.objects.get(pk=self.old_course.pk).code, 'NEW1500') 66 | 67 | def test_replace_code_merge(self): 68 | Course.objects.create(code='NEW1500', main_teacher=self.old_teacher) 69 | replace_course_code_multi('CS1500', 'NEW1500') 70 | self.assertFalse(Course.objects.filter(pk=self.old_course.pk).exists()) 71 | new_course = Course.objects.get(code='NEW1500', main_teacher=self.old_teacher) 72 | self.assertEqual(Review.objects.get(pk=self.old_review.pk).course_id, new_course.pk) 73 | self.assertEqual(new_course.review_count, 1) 74 | self.assertEqual(new_course.review_avg, 3) 75 | 76 | 77 | class MergeUserTestCase(TestCase): 78 | def setUp(self): 79 | create_test_env() 80 | old_name = hash_username("test1") 81 | new_name = hash_username("test2") 82 | self.old_user = User.objects.create(username=old_name) 83 | self.new_user = User.objects.create(username=new_name) 84 | self.review = create_review(old_name, 'CS1500', 3) 85 | self.course = Course.objects.get(code='CS1500') 86 | self.teacher = Teacher.objects.get(tid=1, name='高女士') 87 | self.semester = Semester.objects.get(name='2021-2022-1') 88 | self.enroll = EnrollCourse.objects.create(user=self.old_user, course=self.course, semester=self.semester) 89 | 90 | def check(self): 91 | self.review.refresh_from_db() 92 | self.enroll.refresh_from_db() 93 | self.assertEqual(self.review.user_id, self.new_user.id) 94 | self.assertEqual(self.enroll.user_id, self.new_user.id) 95 | 96 | def test_merge_user(self): 97 | merge_user(self.old_user, self.new_user) 98 | self.check() 99 | 100 | def test_merge_user_by_id(self): 101 | merge_user_by_id(self.old_user.id, self.new_user.id) 102 | self.check() 103 | 104 | def test_merge_user_by_raw_account(self): 105 | merge_user_by_raw_account("test1", "test2") 106 | self.check() 107 | 108 | 109 | class RenameUserTest(TestCase): 110 | def setUp(self): 111 | name = hash_username("test") 112 | self.user = User.objects.create(username=name) 113 | 114 | def test_rename_user(self): 115 | rename_user(self.user, "test2") 116 | self.user.refresh_from_db() 117 | self.assertEqual(self.user.username, "test2") 118 | 119 | def test_rename_user_by_raw(self): 120 | result = rename_user_raw_account("test", "test2") 121 | self.assertTrue(result) 122 | self.user.refresh_from_db() 123 | name = hash_username("test2") 124 | self.assertEqual(self.user.username, name) 125 | 126 | 127 | class ExportTest(TestCase): 128 | 129 | def setUp(self) -> None: 130 | create_test_env() 131 | 132 | def test_export(self): 133 | sio = StringIO() 134 | export_courses_to_csv(sio) 135 | self.assertEqual(sio.getvalue(), 136 | "code,name,main_teacher,id\r\n" 137 | f"CS1500,计算机科学导论,高女士,{Course.objects.get(code='CS1500').pk}\r\n" 138 | f"CS2500,算法与复杂性,高女士,{Course.objects.get(code='CS2500').pk}\r\n" 139 | f"MARX1001,思想道德修养与法律基础,梁女士,{Course.objects.get(code='MARX1001', main_teacher__name='梁女士').pk}\r\n" 140 | f"MARX1001,思想道德修养与法律基础,赵先生,{Course.objects.get(code='MARX1001', main_teacher__name='赵先生').pk}\r\n") 141 | sio.close() 142 | -------------------------------------------------------------------------------- /jcourse_api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from rest_framework.routers import DefaultRouter 3 | 4 | from jcourse_api.views import * 5 | from jcourse_api.views.common import get_common_info 6 | 7 | router = DefaultRouter() 8 | router.register('course', CourseViewSet, basename='course') 9 | router.register('review', ReviewViewSet, basename='review') 10 | router.register('semester', SemesterViewSet, basename='semester') 11 | router.register('course-in-review', CourseInReviewViewSet, basename='course-in-review') 12 | router.register('announcement', AnnouncementViewSet, basename='announcement') 13 | router.register('search', SearchViewSet, basename='search') 14 | router.register('lesson', EnrollCourseViewSet, basename='lesson') 15 | router.register('report', ReportViewSet, basename='report') 16 | router.register('notification', NotificationViewSet, basename='notification') 17 | 18 | 19 | urlpatterns = [ 20 | path('', include(router.urls)), 21 | path('me/', UserView.as_view(), name='me'), 22 | path('course-filter/', CourseFilterView.as_view(), name='course-filter'), 23 | path('review-filter/', ReviewFilterView.as_view(), name='review-filter'), 24 | path('statistic/', StatisticView.as_view(), name='statistic'), 25 | path('points/', UserPointView.as_view(), name='user-points'), 26 | path('sync-lessons//', sync_lessons, name='sync-lessons'), 27 | path('sync-lessons/', sync_lessons, name='sync-lessons'), 28 | path('sync-lessons-v2/', sync_lessons_v2, name='sync-lessons-v2'), 29 | path('course//review/', ReviewInCourseView.as_view(), name='review-in-course'), 30 | path('review//revision/', ReviewRevisionView.as_view(), name='review-revision'), 31 | path('common/', get_common_info, name='common-info'), 32 | path('debug/', debug_info, name='debug-info'), 33 | # path('upload/', FileUploadView.as_view(), name='upload'), 34 | ] 35 | -------------------------------------------------------------------------------- /jcourse_api/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .merge_course import * 2 | from .merge_user import * 3 | from .export import * 4 | from .email import * 5 | from .spam import * 6 | from .duplicate import * 7 | from .rename import * 8 | -------------------------------------------------------------------------------- /jcourse_api/utils/duplicate.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.db.models import Count, Q 4 | 5 | from jcourse_api.models import Teacher 6 | from jcourse_api.utils import merge_teacher 7 | 8 | 9 | def find_duplicated_teachers(): 10 | dup = Teacher.objects.select_related("department").values("name", "department__name").annotate( 11 | count=Count("id")).filter(count__gt=1) 12 | q = Q() 13 | for d in dup: 14 | q = q | Q(name=d["name"], department__name=d["department__name"]) 15 | candidates = Teacher.objects.select_related("department", "last_semester").filter(q) 16 | result = dict() 17 | for candidate in candidates: 18 | k = (candidate.name, candidate.department.name) 19 | v = (candidate, candidate.last_semester.name if candidate.last_semester else "2019-2020-1") 20 | if k in result: 21 | ex_v = result[k] 22 | if ex_v[1] > v[1]: # 存在且学期更新 23 | continue 24 | result[k] = v 25 | return result, candidates 26 | 27 | 28 | def check_tid(tid: str) -> bool: 29 | reg = re.compile("^[0-9]{5}$") 30 | return reg.match(tid) is None 31 | 32 | 33 | def merge_duplicated_teachers(): 34 | result, candidates = find_duplicated_teachers() 35 | for candidate in candidates: 36 | target = result[(candidate.name, candidate.department.name)][0] 37 | if target.id != candidate.id \ 38 | and target.last_semester != candidate.last_semester \ 39 | and check_tid(candidate.tid): 40 | print(candidate.name, candidate.tid, candidate.last_semester, target, target.tid, target.last_semester) 41 | merge_teacher(candidate, target) 42 | -------------------------------------------------------------------------------- /jcourse_api/utils/email.py: -------------------------------------------------------------------------------- 1 | from django.core.mail import send_mail 2 | 3 | from jcourse import settings 4 | 5 | 6 | def send_admin_email(title: str, body: str): 7 | try: 8 | send_mail(title, body, from_email=settings.DEFAULT_FROM_EMAIL, 9 | recipient_list=[settings.ADMIN_EMAIL]) 10 | except: 11 | pass 12 | -------------------------------------------------------------------------------- /jcourse_api/utils/export.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from typing import IO 3 | 4 | from jcourse_api.models import Course 5 | 6 | 7 | def export_courses_to_csv(f: IO): 8 | csv_writer = csv.writer(f) 9 | courses_output = [] 10 | courses = Course.objects.all().prefetch_related('main_teacher') 11 | for course in courses: 12 | courses_output.append([course.code, course.name, course.main_teacher.name, course.pk]) 13 | csv_writer.writerow(['code', 'name', 'main_teacher', 'id']) 14 | csv_writer.writerows(courses_output) 15 | -------------------------------------------------------------------------------- /jcourse_api/utils/merge_course.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from jcourse_api.models import * 4 | 5 | 6 | def merge_course(old_course: Course, new_course: Course) -> bool: 7 | if old_course == new_course: 8 | return False 9 | reviews = Review.objects.filter(course=old_course) 10 | reviews.update(course=new_course) 11 | update_course_reviews(new_course) 12 | # 查询同时选了两门课的用户,删除旧记录 13 | new_enrolls = EnrollCourse.objects.filter(course=new_course).values('user') 14 | common = EnrollCourse.objects.filter(user__in=new_enrolls, course=old_course) 15 | if common.exists(): 16 | common.delete() 17 | # 更新记录 18 | EnrollCourse.objects.filter(course=old_course).update(course=new_course) 19 | old_course.delete() 20 | return True 21 | 22 | 23 | def merge_course_by_id(old_id: int, new_id: int, pre_func: Callable[[Course, Course], None] = None) -> bool: 24 | if old_id == new_id: 25 | return False 26 | try: 27 | old_course = Course.objects.get(pk=old_id) 28 | new_course = Course.objects.get(pk=new_id) 29 | if pre_func: 30 | pre_func(old_course, new_course) 31 | return merge_course(old_course, new_course) 32 | except Course.DoesNotExist: 33 | return False 34 | 35 | 36 | def replace_course_code_multi(old_code: str, new_code: str, 37 | pre_merge: Callable[[Course, Course], None] = None, 38 | pre_replace: Callable[[Course], None] = None): 39 | if old_code == new_code: 40 | return 41 | courses = Course.objects.filter(code=old_code).prefetch_related('main_teacher') 42 | for course in courses: 43 | try: 44 | new_code_course = Course.objects.get(code=new_code, main_teacher=course.main_teacher) 45 | if pre_merge: 46 | pre_merge(course, new_code_course) 47 | merge_course(course, new_code_course) 48 | except Course.DoesNotExist: 49 | if pre_replace: 50 | pre_replace(course) 51 | course.code = new_code 52 | course.save() 53 | continue 54 | 55 | 56 | def merge_teacher(old_teacher: Teacher, new_teacher: Teacher) -> bool: 57 | if old_teacher == new_teacher: 58 | return False 59 | old_courses = Course.objects.filter(main_teacher=old_teacher) 60 | for course in old_courses: 61 | try: 62 | new_course = Course.objects.get(code=course.code, main_teacher=new_teacher) 63 | merge_course(course, new_course) 64 | except Course.DoesNotExist: 65 | course.main_teacher = new_teacher 66 | course.save() 67 | old_teacher.delete() 68 | return True 69 | 70 | 71 | def merge_teacher_by_id(old_id: int, new_id: int, 72 | pre_func: Callable[[Teacher, Teacher], None] = None) -> bool: 73 | if old_id == new_id: 74 | return False 75 | try: 76 | old_teacher = Teacher.objects.get(pk=old_id) 77 | new_teacher = Teacher.objects.get(pk=new_id) 78 | if pre_func: 79 | pre_func(old_teacher, new_teacher) 80 | merge_teacher(old_teacher, new_teacher) 81 | return True 82 | except Teacher.DoesNotExist: 83 | return False 84 | -------------------------------------------------------------------------------- /jcourse_api/utils/merge_user.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | 3 | from jcourse_api.models import * 4 | from oauth.models import * 5 | from oauth.utils import hash_username 6 | 7 | 8 | def merge_user(old_user: User, new_user: User) -> bool: 9 | with transaction.atomic(): 10 | Review.objects.filter(user=old_user).update(user=new_user) 11 | ReviewReaction.objects.filter(user=old_user).update(user=new_user) 12 | Report.objects.filter(user=old_user).update(user=new_user) 13 | EnrollCourse.objects.filter(user=old_user).update(user=new_user) 14 | UserPoint.objects.filter(user=old_user).update(user=new_user) 15 | Notification.objects.filter(recipient=old_user).update(recipient=new_user) 16 | CourseNotificationLevel.objects.filter(user=old_user).update(user=new_user) 17 | UserProfile.objects.filter(user=old_user).update(user=new_user) 18 | old_user.delete() 19 | return True 20 | 21 | 22 | def merge_user_by_id(old_id: int, new_id: int) -> bool: 23 | if old_id == new_id: 24 | return False 25 | try: 26 | old_user = User.objects.get(pk=old_id) 27 | new_user = User.objects.get(pk=new_id) 28 | return merge_user(old_user, new_user) 29 | except Course.DoesNotExist: 30 | return False 31 | 32 | 33 | def merge_user_by_raw_account(old_account: str, new_account: str) -> bool: 34 | if old_account == new_account: 35 | return False 36 | try: 37 | old_name = hash_username(old_account) 38 | new_name = hash_username(new_account) 39 | old_user = User.objects.get(username=old_name) 40 | new_user = User.objects.get(username=new_name) 41 | return merge_user(old_user, new_user) 42 | except Course.DoesNotExist: 43 | return False 44 | -------------------------------------------------------------------------------- /jcourse_api/utils/point.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db.models import Sum 3 | 4 | from jcourse_api.models import Review, UserPoint 5 | 6 | 7 | def get_user_point_with_reviews(user: User, reviews): 8 | courses = reviews.values_list('course', flat=True) 9 | approves_count = reviews.aggregate(count=Sum('approve_count'))['count'] 10 | if approves_count is None: 11 | approves_count = 0 12 | reviews_count = reviews.count() 13 | 14 | first_reviews = Review.objects.filter(course__in=courses).order_by('course_id', 'created_at').distinct( 15 | 'course_id').values_list('id', flat=True) 16 | first_reviews = first_reviews.intersection(reviews) 17 | first_reviews_count = first_reviews.count() 18 | first_reviews_approves_count = Review.objects.filter(pk__in=first_reviews).aggregate(count=Sum('approve_count'))[ 19 | 'count'] 20 | if first_reviews_approves_count is None: 21 | first_reviews_approves_count = 0 22 | additional = UserPoint.objects.filter(user=user) 23 | additional_point = additional.aggregate(sum=Sum('value'))['sum'] 24 | if additional_point is None: 25 | additional_point = 0 26 | points = additional_point + approves_count + first_reviews_approves_count + reviews_count + first_reviews_count 27 | return points, additional_point 28 | -------------------------------------------------------------------------------- /jcourse_api/utils/rename.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from oauth.utils import hash_username 4 | 5 | 6 | def rename_user(user: User, new_name: str): 7 | user.username = new_name 8 | user.save(update_fields=["username"]) 9 | 10 | 11 | def rename_user_by_name(old_name: str, new_name: str) -> bool: 12 | try: 13 | user = User.objects.get(username=old_name) 14 | rename_user(user, new_name) 15 | except User.DoesNotExist: 16 | return False 17 | return True 18 | 19 | 20 | def rename_user_raw_account(old_account: str, new_account: str): 21 | old_name = hash_username(old_account) 22 | new_name = hash_username(new_account) 23 | return rename_user_by_name(old_name, new_name) 24 | -------------------------------------------------------------------------------- /jcourse_api/utils/spam.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import difflib 3 | 4 | from django.contrib.auth.models import User 5 | from django.db.models import QuerySet 6 | 7 | import utils.common 8 | from jcourse_api.models import Review, Course 9 | from jcourse_api.tasks import send_antispam_email 10 | from oauth.utils import get_user_profile 11 | 12 | SPAM_MAX_REVIEWS = 3 13 | SPAM_PERIOD_MINUTES = 5 14 | SPAM_SIMILAR_RATIO = 0.85 15 | 16 | 17 | def similar_rule(data: dict, reviews: QuerySet): 18 | comment = data["comment"] 19 | count = 0 20 | s = difflib.SequenceMatcher(lambda x: x in " \t\n\r:,.:,。") 21 | s.set_seq1(comment) 22 | for review in reviews: 23 | s.set_seq2(review.comment) 24 | if s.quick_ratio() > SPAM_SIMILAR_RATIO: 25 | count = count + 1 26 | return count * 2 >= SPAM_MAX_REVIEWS 27 | 28 | 29 | def course_rule(data: dict, reviews: QuerySet): 30 | count = 0 31 | try: 32 | course = Course.objects.get(pk=data["course"]) 33 | except Course.DoesNotExist: 34 | return False 35 | for review in reviews: 36 | if review.course.code == course.code: 37 | count = count + 1 38 | return count == SPAM_MAX_REVIEWS 39 | 40 | 41 | def check_spam(user: User, data, time: datetime.datetime): 42 | # find review history 43 | time_threshold = time - datetime.timedelta(minutes=SPAM_PERIOD_MINUTES) 44 | reviews = Review.objects.select_related("course").filter(user=user, created_at__gt=time_threshold).order_by( 45 | "-created_at")[:SPAM_MAX_REVIEWS] 46 | 47 | if reviews.count() + 1 < SPAM_MAX_REVIEWS: 48 | return False 49 | if course_rule(data, reviews): 50 | return True 51 | if similar_rule(data, reviews): 52 | return True 53 | 54 | return False 55 | 56 | 57 | def deal_with_spam(user: User, data: dict): 58 | suspended_till = utils.common.get_time_now() + datetime.timedelta(days=SPAM_MAX_REVIEWS * 30) 59 | userprofile = get_user_profile(user) 60 | userprofile.suspended_till = suspended_till 61 | userprofile.save(update_fields=['suspended_till']) 62 | send_antispam_email(user.username, data) 63 | -------------------------------------------------------------------------------- /jcourse_api/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .course import * 3 | from .enroll import * 4 | from .notification import * 5 | from .review import * 6 | from .site import * 7 | from .user import * 8 | -------------------------------------------------------------------------------- /jcourse_api/views/base.py: -------------------------------------------------------------------------------- 1 | from django.db.models import F 2 | from django.utils.decorators import method_decorator 3 | from django.views.decorators.cache import cache_page 4 | from rest_framework import viewsets, status 5 | from rest_framework.request import Request 6 | from rest_framework.response import Response 7 | from rest_framework.views import APIView 8 | 9 | from jcourse_api.models import * 10 | from jcourse_api.repository import get_semesters 11 | from jcourse_api.serializers import SemesterSerializer, CategorySerializer, DepartmentSerializer 12 | 13 | 14 | class SemesterViewSet(viewsets.ReadOnlyModelViewSet): 15 | queryset = get_semesters() 16 | serializer_class = SemesterSerializer 17 | pagination_class = None 18 | 19 | @method_decorator(cache_page(60)) 20 | def dispatch(self, request: Request, *args, **kwargs): 21 | return super().dispatch(request, *args, **kwargs) 22 | 23 | 24 | class CourseFilterView(APIView): 25 | 26 | def get(self, request: Request): 27 | categories = Category.objects.annotate(count=Count('course')).filter(count__gt=0) 28 | category_serializer = CategorySerializer(categories, many=True) 29 | departments = Department.objects.annotate(count=Count('course')).filter(count__gt=0) 30 | department_serializer = DepartmentSerializer(departments, many=True) 31 | return Response({'categories': category_serializer.data, 'departments': department_serializer.data}, 32 | status=status.HTTP_200_OK) 33 | 34 | 35 | class ReviewFilterView(APIView): 36 | 37 | def get(self, request: Request): 38 | course_id = request.query_params.get('course_id') 39 | reviews = Review.objects.select_related("semester") 40 | if course_id: 41 | reviews = reviews.filter(course__id=course_id) 42 | semesters = reviews.values('semester') \ 43 | .annotate(count=Count('semester'), name=F("semester__name"), id=F("semester__id"), avg=Avg('rating')) \ 44 | .filter(count__gt=0).values("id", "name", "count", "avg").order_by(F('name').desc()) 45 | ratings = reviews.values('rating').annotate(count=Count('rating')).filter(count__gt=0).order_by( 46 | F('rating').desc()) 47 | return Response({'semesters': semesters, 'ratings': ratings}, 48 | status=status.HTTP_200_OK) 49 | -------------------------------------------------------------------------------- /jcourse_api/views/common.py: -------------------------------------------------------------------------------- 1 | from rest_framework.decorators import api_view 2 | from rest_framework.response import Response 3 | 4 | from ad.repository import get_promotions 5 | from ad.serializers import PromotionSerializer 6 | from jcourse_api.repository import * 7 | from jcourse_api.serializers import * 8 | 9 | 10 | # one request for all common info (announcements, semesters, enrolled courses, reviewed courses, user, promotions) 11 | @api_view(['GET']) 12 | def get_common_info(request): 13 | user = request.user 14 | announcements = get_announcements() 15 | semesters = get_semesters() 16 | enrolled_courses = get_enrolled_courses(user) 17 | my_reviews = get_my_reviewed(user) 18 | promotions = get_promotions() 19 | 20 | return Response({"user": UserSerializer(user).data, 21 | "announcements": AnnouncementSerializer(announcements, many=True).data, 22 | "semesters": SemesterSerializer(semesters, many=True).data, 23 | "enrolled_courses": enrolled_courses, 24 | "my_reviews": my_reviews, 25 | "promotions": PromotionSerializer(promotions, many=True).data 26 | }) 27 | -------------------------------------------------------------------------------- /jcourse_api/views/course.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django.db.models import F 3 | from django_filters import BaseInFilter, NumberFilter 4 | from django_filters.rest_framework import DjangoFilterBackend 5 | from rest_framework import viewsets, status, mixins 6 | from rest_framework.decorators import action 7 | from rest_framework.request import Request 8 | from rest_framework.response import Response 9 | 10 | from jcourse_api.models import * 11 | from jcourse_api.repository import get_course_list_queryset, get_search_course_queryset 12 | from jcourse_api.serializers import CourseListSerializer, CourseSerializer, CourseInWriteReviewSerializer 13 | 14 | 15 | class NumberInFilter(BaseInFilter, NumberFilter): 16 | pass 17 | 18 | 19 | class CourseFilter(django_filters.FilterSet): 20 | categories = NumberInFilter(field_name="categories", lookup_expr="in") 21 | department = NumberInFilter(field_name="department", lookup_expr="in") 22 | 23 | class Meta: 24 | model = Course 25 | fields = ['categories', 'department'] 26 | 27 | 28 | class CourseViewSet(viewsets.ReadOnlyModelViewSet): 29 | filter_backends = [DjangoFilterBackend] 30 | filterset_class = CourseFilter 31 | 32 | def get_queryset(self): 33 | courses = get_course_list_queryset(self.request.user) 34 | if 'notification_level' in self.request.query_params: 35 | notification_level = int(self.request.query_params['notification_level']) 36 | filtered_course_ids = CourseNotificationLevel.objects.filter(user=self.request.user, 37 | notification_level=notification_level) \ 38 | .order_by('modified_at').values('course_id') 39 | courses = courses.filter(id__in=filtered_course_ids) 40 | if 'onlyhasreviews' in self.request.query_params: 41 | courses = courses.filter(review_count__gt=0). \ 42 | annotate(count=F('review_count'), avg=F('review_avg')) 43 | if self.request.query_params['onlyhasreviews'] == 'count': 44 | return courses.order_by(F('count').desc(nulls_last=True), F('avg').desc(nulls_last=True), "id") 45 | return courses.order_by(F('avg').desc(nulls_last=True), F('count').desc(nulls_last=True), "id") 46 | return courses.all() 47 | 48 | def get_serializer_class(self): 49 | if self.action == 'list': 50 | return CourseListSerializer 51 | else: 52 | return CourseSerializer 53 | 54 | # def get_ 55 | @action(detail=True, methods=['POST']) 56 | def notification_level(self, request: Request, pk=None): 57 | if 'level' not in request.data: 58 | return Response({'error': '未指定操作类型!'}, status=status.HTTP_400_BAD_REQUEST) 59 | notification_level = int(request.data['level']) 60 | if notification_level not in CourseNotificationLevel.NotificationLevelType: 61 | return Response({'error': '无效的操作类型!'}, status=status.HTTP_400_BAD_REQUEST) 62 | 63 | course: Course = self.get_object() 64 | course_notification, find = CourseNotificationLevel.objects.update_or_create( 65 | user=request.user, 66 | course=course, 67 | defaults={'notification_level': notification_level, 'modified_at': timezone.now()} 68 | ) 69 | 70 | return Response({'id': pk, 71 | 'notification_level': course_notification.notification_level}, 72 | status=status.HTTP_200_OK) 73 | 74 | 75 | class SearchViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 76 | serializer_class = CourseListSerializer 77 | 78 | def get_queryset(self): 79 | q = self.request.query_params.get('q', '') 80 | return get_search_course_queryset(q, self.request.user) 81 | 82 | 83 | class CourseInReviewViewSet(viewsets.ReadOnlyModelViewSet): 84 | serializer_class = CourseInWriteReviewSerializer 85 | 86 | def get_queryset(self): 87 | if self.action == 'list': 88 | q = self.request.query_params.get('q', '') 89 | return get_search_course_queryset(q, self.request.user) 90 | elif self.action == 'retrieve': 91 | return get_course_list_queryset(self.request.user) 92 | -------------------------------------------------------------------------------- /jcourse_api/views/enroll.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, status 2 | from rest_framework.decorators import api_view 3 | from rest_framework.request import Request 4 | from rest_framework.response import Response 5 | 6 | from jcourse_api.models import * 7 | from jcourse_api.repository import get_course_list_queryset 8 | from jcourse_api.serializers import CourseListSerializer 9 | from oauth.utils import jaccount 10 | 11 | 12 | def parse_jaccount_courses(response: dict): 13 | codes = [] 14 | teachers = [] 15 | for entity in response['entities']: 16 | codes.append(entity['course']['code']) 17 | teachers.append(entity['teachers'][0]['name']) 18 | return codes, teachers 19 | 20 | 21 | def find_exist_course_ids(codes: list, teachers: list): 22 | conditions = Q(pk=None) 23 | for code, teacher in zip(codes, teachers): 24 | conditions = conditions | (Q(code=code) & Q(main_teacher__name=teacher)) 25 | return Course.objects.filter(conditions).values_list('id', flat=True) 26 | 27 | 28 | def sync_enroll_course(user: User, course_ids: list, term: str): 29 | try: 30 | semester = Semester.objects.get(name=term) 31 | except Semester.DoesNotExist: 32 | semester = None 33 | enroll_courses = [] 34 | for course_id in course_ids: 35 | enroll_courses.append(EnrollCourse(user=user, course_id=course_id, semester=semester)) 36 | # remove withdrawn courses 37 | EnrollCourse.objects.filter(user=user, semester=semester).exclude(course_id__in=course_ids).delete() 38 | EnrollCourse.objects.bulk_create(enroll_courses, ignore_conflicts=True) 39 | 40 | 41 | def get_jaccount_lessons(token: dict, term: str): 42 | return jaccount.get(f'v1/me/lessons/{term}/', token=token, params={"classes": False}).json() 43 | 44 | 45 | @api_view(['POST']) 46 | def sync_lessons(request: Request, term: str = '2018-2019-2'): 47 | token = request.session.get('token', None) 48 | if token is None: 49 | return Response({'detail': '未授权获取课表信息'}, status=status.HTTP_401_UNAUTHORIZED) 50 | resp = get_jaccount_lessons(token, term) 51 | if resp['errno'] == 0: 52 | codes, teachers = parse_jaccount_courses(resp) 53 | existed_courses_ids = find_exist_course_ids(codes, teachers) 54 | sync_enroll_course(request.user, existed_courses_ids, term) 55 | 56 | courses = get_course_list_queryset(request.user) 57 | courses = courses.filter(enrollcourse__user=request.user) 58 | serializer = CourseListSerializer(courses, many=True) 59 | return Response(serializer.data) 60 | 61 | 62 | def find_existing_course_v2(data: dict): 63 | codes = [item["code"] for item in data] 64 | conditions = Q(pk=None) 65 | for item in data: 66 | teacher = item["teachers"].split(",")[0] 67 | conditions = conditions | (Q(code=item["code"]) & Q(main_teacher__name=teacher)) 68 | return Course.objects.filter(conditions).values_list('id', flat=True) 69 | 70 | 71 | @api_view(['POST']) 72 | def sync_lessons_v2(request: Request): 73 | if len(request.data) == 0: 74 | return Response({'detail': '至少需要提交一条课表信息'}, status=status.HTTP_400_BAD_REQUEST) 75 | semester = request.data[0]["semester"] 76 | existed_courses_ids = find_existing_course_v2(request.data) 77 | sync_enroll_course(request.user, existed_courses_ids, semester) 78 | return Response({'detail': 'ok'}) 79 | 80 | 81 | class EnrollCourseViewSet(viewsets.ReadOnlyModelViewSet): 82 | serializer_class = CourseListSerializer 83 | pagination_class = None 84 | 85 | def get_queryset(self): 86 | courses = get_course_list_queryset(self.request.user) 87 | return courses.filter(enrollcourse__user=self.request.user) 88 | -------------------------------------------------------------------------------- /jcourse_api/views/notification.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, status 2 | from rest_framework.decorators import action 3 | from rest_framework.request import Request 4 | from rest_framework.response import Response 5 | 6 | from jcourse_api.models import * 7 | from jcourse_api.serializers import NotificationSerializer 8 | 9 | 10 | class NotificationViewSet(viewsets.ReadOnlyModelViewSet): 11 | serializer_class = NotificationSerializer 12 | 13 | def get_queryset(self): 14 | return Notification.objects.filter(recipient=self.request.user, public=True).order_by('-created_at') 15 | 16 | @action(detail=True, methods=['POST']) 17 | def read(self, request: Request, pk=None): 18 | if 'read' not in request.data: 19 | return Response({'error': '未指定操作类型!'}, status=status.HTTP_400_BAD_REQUEST) 20 | notification: Notification = self.get_object() 21 | if notification.recipient != request.user: 22 | return Response({'error': '无权操作!'}, status=status.HTTP_403_FORBIDDEN) 23 | if int(request.data['read']): 24 | notification.read_at = timezone.now() 25 | else: 26 | notification.read_at = None 27 | notification.save() 28 | 29 | return Response({'id': pk, 30 | 'read_at': notification.read_at}, 31 | status=status.HTTP_200_OK) 32 | -------------------------------------------------------------------------------- /jcourse_api/views/site.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db.models import Count, F 3 | from django.db.models.functions import TruncDate, Floor 4 | from django.utils.decorators import method_decorator 5 | from django.views.decorators.cache import cache_page 6 | from rest_framework import viewsets, status 7 | from rest_framework.decorators import api_view, permission_classes 8 | from rest_framework.permissions import IsAdminUser 9 | from rest_framework.request import Request 10 | from rest_framework.response import Response 11 | from rest_framework.views import APIView 12 | 13 | from jcourse_api.models import Review, Course 14 | from jcourse_api.repository import get_announcements 15 | from jcourse_api.serializers import AnnouncementSerializer 16 | 17 | 18 | class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet): 19 | queryset = get_announcements() 20 | serializer_class = AnnouncementSerializer 21 | pagination_class = None 22 | 23 | 24 | class StatisticView(APIView): 25 | 26 | @method_decorator(cache_page(60)) 27 | def get(self, request: Request): 28 | user_join_time = User.objects.values(date=TruncDate("date_joined")).annotate( 29 | count=Count("id")).order_by("date") 30 | review_create_time = Review.objects.values(date=TruncDate("created_at")).annotate( 31 | count=Count("id")).order_by("date") 32 | course_review_count_dist = Course.objects.filter(review_count__gt=0).values(value=F("review_count")).annotate( 33 | count=Count("value")).order_by("value") 34 | course_review_avg_dist = Course.objects.filter(review_avg__gt=0).values(value=Floor("review_avg")).annotate( 35 | count=Count("value")).order_by("value") 36 | review_rating_dist = Review.objects.values(value=F("rating")).annotate( 37 | count=Count("value")).order_by("value") 38 | return Response({'course_count': Course.objects.count(), 39 | 'course_with_review_count': Course.objects.filter(review_count__gt=0).count(), 40 | 'user_count': User.objects.count(), 41 | 'review_count': Review.objects.count(), 42 | 'user_join_time': user_join_time, 43 | 'review_create_time': review_create_time, 44 | 'course_review_count_dist': course_review_count_dist, 45 | 'course_review_avg_dist': course_review_avg_dist, 46 | 'review_rating_dist': review_rating_dist 47 | }, 48 | status=status.HTTP_200_OK) 49 | 50 | 51 | @api_view(['GET']) 52 | @permission_classes([IsAdminUser]) 53 | def debug_info(request): 54 | user_agent = request.META.get('HTTP_USER_AGENT', '') 55 | xff = request.META.get('HTTP_X_FORWARDED_FOR') 56 | remote_addr = request.META.get('REMOTE_ADDR') 57 | return Response({'HTTP_USER_AGENT': user_agent, 58 | 'HTTP_X_FORWARDED_FOR': xff, 59 | 'REMOTE_ADDR': remote_addr}, 60 | status=status.HTTP_200_OK) -------------------------------------------------------------------------------- /jcourse_api/views/upload.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import io 3 | 4 | from django.core.files.uploadedfile import InMemoryUploadedFile 5 | from rest_framework import status 6 | from rest_framework.parsers import FileUploadParser 7 | from rest_framework.permissions import IsAdminUser 8 | from rest_framework.response import Response 9 | from rest_framework.views import APIView 10 | 11 | from jcourse_api.serializers import * 12 | from utils.course_data_clean import UploadData 13 | 14 | 15 | def get_id_mapping(name: str) -> dict[str, int]: 16 | if name == "department": 17 | department_id = {} 18 | departments = Department.objects.values_list('id', 'name') 19 | for department in departments: 20 | department_id[department[1]] = department[0] 21 | return department_id 22 | elif name == "category": 23 | category_id = {} 24 | categories = Category.objects.values_list('id', 'name') 25 | for category in categories: 26 | category_id[category[1]] = category[0] 27 | return category_id 28 | elif name == "teacher": 29 | teacher_id = {} 30 | teachers = Teacher.objects.values_list('id', 'tid') 31 | for teacher in teachers: 32 | teacher_id[teacher[1]] = teacher[0] 33 | return teacher_id 34 | elif name == "course": 35 | course_id = {} 36 | courses = Course.objects.values_list('id', 'code') 37 | for course in courses: 38 | course_id[course[1]] = course[0] 39 | return course_id 40 | 41 | 42 | def pre_import(data): 43 | departments: list[Department] = [] 44 | categories: list[Category] = [] 45 | 46 | for department in data.departments: 47 | d = Department(name=department) 48 | departments.append(d) 49 | Department.objects.bulk_create(departments, ignore_conflicts=True) 50 | 51 | for category in data.categories: 52 | c = Category(name=category) 53 | categories.append(c) 54 | Category.objects.bulk_create(categories, ignore_conflicts=True) 55 | 56 | 57 | def import_dependent_data(data, semester: str): 58 | teachers = [] 59 | courses = [] 60 | department_id = get_id_mapping("department") 61 | semester, _ = Semester.objects.get_or_create(name=semester) 62 | category_id = get_id_mapping("category") 63 | 64 | for teacher in data.teachers: 65 | t = Teacher(tid=teacher[0], name=teacher[1], title=teacher[2], 66 | department_id=department_id[teacher[3]], 67 | pinyin=teacher[4], abbr_pinyin=teacher[5], 68 | last_semester=semester) 69 | teachers.append(t) 70 | created_teachers = Teacher.objects.bulk_create(teachers, ignore_conflicts=True) 71 | 72 | teacher_id = get_id_mapping("teacher") 73 | for course in data.courses: 74 | c = Course(code=course[0], name=course[1], credit=course[2], 75 | department_id=department_id[course[3]], 76 | main_teacher_id=teacher_id[course[5]], 77 | last_semester=semester) 78 | courses.append(c) 79 | created_courses = Course.objects.bulk_create(courses, ignore_conflicts=True) 80 | 81 | course_id = get_id_mapping("course") 82 | categories = [] 83 | teacher_group = [] 84 | for course in data.courses: 85 | course_categories: str = course[4] 86 | if course_categories != "": 87 | for course_category in course_categories.split(";"): 88 | course_category = Course.categories.through(course_id=course_id[course[0]], 89 | category_id=category_id[course_category]) 90 | categories.append(course_category) 91 | 92 | course_teachers: str = course[6] 93 | for course_teacher in course_teachers.split(";"): 94 | course_teacher = Course.teacher_group.through(course_id=course_id[course[0]], 95 | teacher_id=teacher_id[course_teacher]) 96 | teacher_group.append(course_teacher) 97 | 98 | Course.categories.through.objects.bulk_create(categories, ignore_conflicts=True) 99 | Course.teacher_group.through.objects.bulk_create(teacher_group, ignore_conflicts=True) 100 | 101 | return created_courses, created_teachers 102 | 103 | 104 | class FileUploadView(APIView): 105 | permission_classes = [IsAdminUser] 106 | parser_class = (FileUploadParser,) 107 | 108 | @staticmethod 109 | def post(request): 110 | if 'file' not in request.data or 'semester' not in request.data: 111 | return Response({"details": "Bad arguments"}, status=status.HTTP_400_BAD_REQUEST) 112 | file: InMemoryUploadedFile = request.data['file'] 113 | semester: str = request.data['semester'] 114 | csv_reader = csv.DictReader(io.StringIO(file.read().decode('utf-8-sig'))) 115 | data = UploadData() 116 | data.clean_data_for_jwc(csv_reader, './script') 117 | pre_import(data) 118 | created_courses, created_teachers = import_dependent_data(data, semester) 119 | resp = {"courses": len(created_courses), "teachers": len(created_teachers)} 120 | return Response(resp, status=status.HTTP_201_CREATED) 121 | -------------------------------------------------------------------------------- /jcourse_api/views/user.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Sum 2 | from django.utils.decorators import method_decorator 3 | from django.views.decorators.cache import cache_page 4 | from django.views.decorators.csrf import csrf_exempt 5 | from django.views.decorators.vary import vary_on_cookie 6 | from rest_framework import mixins, viewsets, status 7 | from rest_framework.permissions import AllowAny 8 | from rest_framework.request import Request 9 | from rest_framework.response import Response 10 | from rest_framework.views import APIView 11 | 12 | from jcourse_api.models import * 13 | from jcourse_api.serializers import ReportSerializer, UserSerializer, UserPointSerializer 14 | from jcourse_api.tasks import send_report_email 15 | from oauth.utils import hash_username 16 | 17 | 18 | class ReportViewSet(mixins.CreateModelMixin, 19 | mixins.ListModelMixin, 20 | viewsets.GenericViewSet): 21 | serializer_class = ReportSerializer 22 | pagination_class = None 23 | 24 | def get_queryset(self): 25 | return Report.objects.filter(user=self.request.user) 26 | 27 | def perform_create(self, serializer: serializer_class): 28 | serializer.save(user=self.request.user) 29 | data = serializer.data 30 | send_report_email(data['comment'], data['created_at']) 31 | 32 | 33 | class UserView(APIView): 34 | 35 | def get(self, request: Request): 36 | """ 37 | 获取当前用户信息 38 | """ 39 | serializer = UserSerializer(request.user) 40 | return Response(serializer.data, status=status.HTTP_200_OK) 41 | 42 | 43 | def get_user_point(user: User): 44 | user_points = UserPoint.objects.filter(user=user) 45 | points = user_points.aggregate(sum=Sum('value'))['sum'] 46 | if points is None: 47 | points = 0 48 | details = UserPointSerializer(user_points, many=True).data 49 | return {'points': points, 'details': details} 50 | 51 | 52 | class UserPointView(APIView): 53 | 54 | def get_permissions(self): 55 | if self.request.method == 'POST': 56 | return [AllowAny()] 57 | else: 58 | return super().get_permissions() 59 | 60 | @method_decorator(cache_page(60)) 61 | @method_decorator(vary_on_cookie) 62 | def get(self, request: Request): 63 | return Response(get_user_point(request.user)) 64 | 65 | @csrf_exempt 66 | def post(self, request: Request): 67 | account = request.data.get('account', '') 68 | apikey = request.headers.get('Api-Key', '') 69 | if account == '' or apikey == '': 70 | return Response({'detail': 'Bad arguments'}, status=status.HTTP_400_BAD_REQUEST) 71 | try: 72 | ApiKey.objects.get(key=apikey, is_enabled=True) 73 | except ApiKey.DoesNotExist: 74 | return Response({'detail': 'Bad arguments'}, status=status.HTTP_400_BAD_REQUEST) 75 | try: 76 | user = User.objects.get(username=hash_username(account), is_active=True) 77 | except User.DoesNotExist: 78 | return Response({'detail': 'Bad arguments'}, status=status.HTTP_400_BAD_REQUEST) 79 | return Response(get_user_point(user)) 80 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'jcourse.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /oauth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/oauth/__init__.py -------------------------------------------------------------------------------- /oauth/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from oauth.models import UserProfile 5 | from oauth.utils import hash_username 6 | 7 | 8 | @admin.register(UserProfile) 9 | class UserProfileAdmin(admin.ModelAdmin): 10 | list_display = ('user', 'user_type', 'lowercase', 'last_seen_at', 'suspended_till') 11 | search_fields = ('user__username',) 12 | list_filter = ('user_type', 'lowercase', 'last_seen_at', 'suspended_till') 13 | readonly_fields = ('user', 'user_type', 'lowercase') 14 | actions = ["reactive_user"] 15 | 16 | def get_search_results(self, request, queryset, search_term): 17 | queryset, may_have_duplicates = super().get_search_results(request, queryset, search_term) 18 | hashed = hash_username(search_term) 19 | queryset |= self.model.objects.filter(user__username=hashed) 20 | return queryset, may_have_duplicates 21 | 22 | @admin.action(description="解封用户") 23 | def reactive_user(self, request, queryset): 24 | for userprofile in queryset: 25 | userprofile.suspended_till = None 26 | userprofile.save(update_fields=["suspended_till"]) 27 | -------------------------------------------------------------------------------- /oauth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OauthConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'oauth' 7 | verbose_name = '登录信息' 8 | -------------------------------------------------------------------------------- /oauth/middlewares.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | 3 | from oauth.tasks import update_last_seen_at 4 | 5 | 6 | class LastSeenAtMiddleware: 7 | def __init__(self, get_response): 8 | self.get_response = get_response 9 | # One-time configuration and initialization. 10 | 11 | def __call__(self, request: HttpRequest): 12 | if request.user.is_authenticated: 13 | update_last_seen_at(request.user) 14 | response = self.get_response(request) 15 | return response 16 | -------------------------------------------------------------------------------- /oauth/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-17 04:46 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='UserProfile', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('user_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='用户类型')), 22 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 23 | ], 24 | options={ 25 | 'verbose_name': '用户信息', 26 | 'verbose_name_plural': '用户信息', 27 | }, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /oauth/migrations/0002_userprofile_lowercase.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2022-08-29 14:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('oauth', '0001_initial'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='userprofile', 14 | name='lowercase', 15 | field=models.BooleanField(default=False, verbose_name='转小写'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /oauth/migrations/0003_userprofile_suspended_till.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-05 09:02 2 | from datetime import timedelta 3 | 4 | from django.contrib.auth.models import User 5 | from django.db import migrations, models 6 | 7 | from utils.common import get_time_now 8 | 9 | 10 | def add_suspended_till(apps, schema_editor): 11 | UserProfile = apps.get_model('oauth', 'UserProfile') 12 | suspended_user_ids = User.objects.filter(is_active=False, is_staff=False).values_list('id', flat=True) 13 | suspended_till = get_time_now() + timedelta(days=90) 14 | UserProfile.objects.filter(user_id__in=suspended_user_ids).update(suspended_till=suspended_till) 15 | 16 | 17 | class Migration(migrations.Migration): 18 | dependencies = [ 19 | ('oauth', '0002_userprofile_lowercase'), 20 | ] 21 | 22 | operations = [ 23 | migrations.AddField( 24 | model_name='userprofile', 25 | name='suspended_till', 26 | field=models.DateTimeField(db_index=True, default=None, blank=True, null=True, verbose_name='封禁到'), 27 | ), 28 | migrations.RunPython(add_suspended_till, reverse_code=migrations.RunPython.noop) 29 | ] 30 | -------------------------------------------------------------------------------- /oauth/migrations/0004_userprofile_last_seen_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-09-03 15:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oauth', '0003_userprofile_suspended_till'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='userprofile', 15 | name='last_seen_at', 16 | field=models.DateTimeField(blank=True, db_index=True, default=None, null=True, verbose_name='活跃时间'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /oauth/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/oauth/migrations/__init__.py -------------------------------------------------------------------------------- /oauth/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | 4 | 5 | # Create your models here. 6 | class UserProfile(models.Model): 7 | class Meta: 8 | verbose_name = '用户信息' 9 | verbose_name_plural = verbose_name 10 | 11 | user = models.OneToOneField(User, on_delete=models.CASCADE, null=False, blank=False) 12 | user_type = models.CharField(verbose_name='用户类型', max_length=50, null=True, blank=True) 13 | lowercase = models.BooleanField(verbose_name='转小写', null=False, default=False) 14 | suspended_till = models.DateTimeField(verbose_name='封禁到', db_index=True, blank=True, default=None, null=True) 15 | last_seen_at = models.DateTimeField(verbose_name='活跃时间', db_index=True, blank=True, default=None, null=True) 16 | 17 | def save(self, force_insert=False, force_update=False, using=None, update_fields=None): 18 | super().save(force_insert, force_update, using, update_fields) 19 | self.sync_suspended_status() 20 | 21 | def sync_suspended_status(self): 22 | self.user.is_active = (self.suspended_till is None) 23 | self.user.save(update_fields=['is_active']) 24 | -------------------------------------------------------------------------------- /oauth/tasks.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from huey import crontab 3 | from huey.contrib.djhuey import db_periodic_task, task 4 | 5 | from oauth.models import UserProfile 6 | from utils.common import get_time_now 7 | 8 | 9 | @db_periodic_task(crontab(day='*/1')) 10 | def release_banned_users(): 11 | now = get_time_now() 12 | should_release_users = UserProfile.objects.filter(suspended_till__isnull=False, 13 | suspended_till__lt=now) 14 | User.objects.filter(userprofile__in=should_release_users).update(is_active=True) 15 | should_release_users.update(suspended_till=None) 16 | 17 | 18 | @task() 19 | def update_last_seen_at(user: User): 20 | profile, _ = UserProfile.objects.get_or_create(user=user) 21 | profile.last_seen_at = get_time_now() 22 | profile.save() 23 | -------------------------------------------------------------------------------- /oauth/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from oauth.views import * 4 | 5 | urlpatterns = [ 6 | path('logout/', auth_logout, name='logout'), 7 | path('login/', auth_login, name='login'), 8 | path('email/send-code/', auth_email_send_code, name='email_send_code'), 9 | path('email/verify/', auth_email_verify_code, name='email_verify_code'), 10 | path('email/login/', auth_email_verify_password, name='email_verify_password'), 11 | path('reset-password/send-code/', reset_password_send_code, name='reset_password_send'), 12 | path('reset-password/reset/', reset_password_reset, name='reset_password_reset'), 13 | path('jaccount/login/', login_jaccount, name='login_jaccount'), 14 | path('jaccount/auth/', auth_jaccount, name='auth_jaccount'), 15 | path('sync-lessons/login/', sync_lessons_login, name='sync-lessons-login'), 16 | path('sync-lessons/auth/', sync_lessons_auth, name='sync-lessons-auth'), 17 | ] 18 | -------------------------------------------------------------------------------- /oauth/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import secrets 3 | import string 4 | 5 | from authlib.integrations.django_client import OAuth 6 | from django.contrib.auth import login 7 | from django.contrib.auth.models import User 8 | from django.core.cache import cache 9 | 10 | import utils.mail 11 | from jcourse import settings 12 | from jcourse.settings import HASH_SALT, EMAIL_VERIFICATION_TIMEOUT, EMAIL_VERIFICATION_MAX_TIMES 13 | from oauth.models import UserProfile 14 | 15 | oauth = OAuth() 16 | oauth.register( 17 | name='jaccount', 18 | client_id=settings.AUTHLIB_OAUTH_CLIENTS['jaccount']['client_id'], 19 | client_secret=settings.AUTHLIB_OAUTH_CLIENTS['jaccount']['client_secret'], 20 | access_token_url='https://jaccount.sjtu.edu.cn/oauth2/token', 21 | authorize_url='https://jaccount.sjtu.edu.cn/oauth2/authorize', 22 | api_base_url='https://api.sjtu.edu.cn/', 23 | client_kwargs={"scope": "basic"} 24 | ) 25 | jaccount = oauth.jaccount 26 | 27 | 28 | def hash_username(username: str): 29 | return hashlib.blake2b((username + HASH_SALT).encode('ascii'), digest_size=16).hexdigest() 30 | 31 | 32 | def generate_code(length: int = 6): 33 | code = ''.join(secrets.choice(string.digits) for _ in range(length)) 34 | return code 35 | 36 | 37 | def build_email_auth_cache_key(account: str): 38 | return f"email_auth_code_{account}" 39 | 40 | 41 | def build_email_auth_times_cache_key(account: str): 42 | return f"email_auth_times_{account}" 43 | 44 | 45 | def auth_store_email_code(account: str, code: str): 46 | cache.set(build_email_auth_cache_key(account), code, EMAIL_VERIFICATION_TIMEOUT * 60) 47 | 48 | 49 | def auth_get_email_code(account: str): 50 | return cache.get(build_email_auth_cache_key(account)) 51 | 52 | 53 | def auth_get_email_tries(account: str): 54 | return cache.get(build_email_auth_times_cache_key(account)) 55 | 56 | 57 | def auth_verify_times(account: str): 58 | times_key = build_email_auth_times_cache_key(account) 59 | times = cache.get_or_set(times_key, 0, EMAIL_VERIFICATION_TIMEOUT * 60) 60 | cache.incr(times_key) 61 | return times < EMAIL_VERIFICATION_MAX_TIMES 62 | 63 | 64 | def auth_verify_email_code(account: str, code: str): 65 | account = account.strip().lower() 66 | code = code.strip() 67 | return code == cache.get(build_email_auth_cache_key(account)) 68 | 69 | 70 | def clean_email_code(account: str): 71 | cache.delete_many([build_email_auth_cache_key(account), build_email_auth_times_cache_key(account)]) 72 | 73 | 74 | def send_code_email(email: str, code: str): 75 | email_title = "选课社区验证码" 76 | email_body = f"您好!\n\n" \ 77 | f"请使用以下验证码完成登录,{EMAIL_VERIFICATION_TIMEOUT}分钟内有效:\n\n" \ 78 | f"{code}\n\n" \ 79 | f"如非本人操作请忽略该邮件。\n\n" \ 80 | f"选课社区" 81 | return send_mail(email_title, email_body, email) 82 | 83 | 84 | def get_or_create_user(account: str): 85 | lower = account.lower() 86 | former_username = hash_username(account) 87 | username = hash_username(lower) 88 | 89 | # 查找旧号存在情况 90 | user = User.objects.filter(username=former_username) 91 | if not user.exists(): # 如果旧号不存在,建新号 92 | user, _ = User.objects.get_or_create(username=username) 93 | return user 94 | # 如果旧号存在,查找新号存在情况 95 | user = User.objects.filter(username=username) 96 | if user.exists(): # 如果新号存在,直接返回新号(未合并旧号,可以管理员操作) 97 | return user.first() 98 | # 如果新号不存在,把修改旧号用户名为新用户名 99 | user = User.objects.get(username=former_username) 100 | user.username = username 101 | user.save() 102 | return user 103 | 104 | 105 | def login_with(request, account: str, user_type: str | None = None): 106 | user = get_or_create_user(account) 107 | if user_type: 108 | UserProfile.objects.update_or_create(user=user, defaults={'user_type': user_type, 'lowercase': True}) 109 | else: 110 | UserProfile.objects.update_or_create(user=user, defaults={'lowercase': True}) 111 | login(request, user) 112 | 113 | 114 | def build_email_reset_cache_key(account: str): 115 | return f"email_reset_code_{account}" 116 | 117 | 118 | def reset_store_email_code(account: str, code: str): 119 | cache.set(build_email_reset_cache_key(account), code, EMAIL_VERIFICATION_TIMEOUT * 60) 120 | 121 | 122 | def reset_send_code_email(email: str, code: str): 123 | email_title = "选课社区验证码" 124 | email_body = f"您好!\n\n" \ 125 | f"请使用以下验证码完成密码重置,{EMAIL_VERIFICATION_TIMEOUT}分钟内有效:\n\n" \ 126 | f"{code}\n\n" \ 127 | f"如非本人操作请忽略该邮件。\n\n" \ 128 | f"选课社区" 129 | return send_mail(email_title, email_body, email) 130 | 131 | def send_mail(title, body, recipient): 132 | if settings.TESTING or settings.BENCHMARK: 133 | return True 134 | 135 | sender = settings.EMAIL_HOST_USER 136 | pwd = settings.EMAIL_HOST_PASSWORD 137 | host = settings.EMAIL_HOST 138 | port = settings.EMAIL_PORT 139 | use_ssl = settings.EMAIL_USE_SSL 140 | try: 141 | utils.mail.send_mail_inner(sender, sender, pwd, [recipient], title, body, host, port, use_ssl) 142 | except Exception as e: 143 | return False 144 | return True 145 | 146 | 147 | def reset_verify_email_code(account: str, code: str): 148 | account = account.strip().lower() 149 | code = code.strip() 150 | return code == cache.get(build_email_reset_cache_key(account)) 151 | 152 | 153 | def reset_get_email_code(account: str): 154 | return cache.get(build_email_reset_cache_key(account)) 155 | 156 | 157 | def reset_clean_email_code(account: str): 158 | cache.delete_many([build_email_reset_cache_key(account)]) 159 | 160 | 161 | def get_user_profile(user: User): 162 | profile, _ = UserProfile.objects.get_or_create(user=user) 163 | return profile 164 | -------------------------------------------------------------------------------- /oauth/views.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | 3 | from authlib.integrations.base_client import OAuthError 4 | from authlib.jose import jwt 5 | from authlib.oidc.core import CodeIDToken 6 | from django.contrib.auth import logout, authenticate 7 | from django.contrib.auth.password_validation import validate_password 8 | from django.core.exceptions import ValidationError 9 | from django.http import JsonResponse 10 | from django.urls import reverse 11 | from django.views.decorators.csrf import csrf_exempt 12 | from rest_framework.decorators import api_view, throttle_classes, permission_classes 13 | from rest_framework.permissions import AllowAny 14 | 15 | from jcourse.throttles import EmailCodeRateThrottle, VerifyAuthRateThrottle 16 | from oauth.utils import * 17 | 18 | 19 | def auth_logout(request): 20 | logout(request) 21 | return JsonResponse({'detail': '已登出。'}) 22 | 23 | 24 | @api_view(['POST']) 25 | @throttle_classes([VerifyAuthRateThrottle]) 26 | @permission_classes([AllowAny]) 27 | @csrf_exempt 28 | def auth_login(request): 29 | username = request.data.get('username') 30 | password = request.data.get('password') 31 | if username is None or password is None: 32 | return JsonResponse({'detail': '参数错误。'}, status=400) 33 | if not auth_verify_times(username): 34 | return JsonResponse({'detail': '尝试次数达到上限,请稍后重试。'}, status=429) 35 | user = authenticate(request, username=username, password=password) 36 | if user is None: 37 | return JsonResponse({'detail': '用户名或密码错误。'}, status=400) 38 | login(request, user) 39 | clean_email_code(username) 40 | return JsonResponse({'account': username}) 41 | 42 | 43 | def login_jaccount(request): 44 | redirect_uri = request.GET.get('redirect_uri', '') 45 | if redirect_uri == '': 46 | redirect_uri = request.build_absolute_uri(reverse('auth_jaccount')) 47 | return jaccount.authorize_redirect(request, redirect_uri) 48 | 49 | 50 | def auth_jaccount(request): 51 | try: 52 | token = jaccount.authorize_access_token(request) 53 | except OAuthError: 54 | return JsonResponse({'detail': '参数错误。'}, status=400) 55 | claims = jwt.decode(token.get('id_token'), 56 | jaccount.client_secret, claims_cls=CodeIDToken) 57 | user_type = claims['type'] 58 | account = claims['sub'] 59 | login_with(request, account, user_type) 60 | response = JsonResponse({'account': account}) 61 | return response 62 | 63 | 64 | def sync_lessons_login(request): 65 | redirect_uri = request.GET.get('redirect_uri', '') 66 | if redirect_uri == '': 67 | redirect_uri = request.build_absolute_uri(reverse('sync-lessons-auth')) 68 | return jaccount.authorize_redirect(request, redirect_uri, scope="basic lessons") 69 | 70 | 71 | def sync_lessons_auth(request): 72 | try: 73 | token = jaccount.authorize_access_token(request) 74 | except OAuthError: 75 | return JsonResponse({'detail': '参数错误。'}, status=400) 76 | request.session['token'] = token 77 | return JsonResponse({'detail': '同步状态就绪。'}) 78 | 79 | 80 | @api_view(['POST']) 81 | @throttle_classes([EmailCodeRateThrottle]) 82 | @permission_classes([AllowAny]) 83 | @csrf_exempt 84 | def auth_email_send_code(request): 85 | account: str = request.data.get("account", None) 86 | if account is None: 87 | return JsonResponse({'detail': '参数错误。'}, status=400) 88 | account = account.strip().lower() 89 | try: 90 | code = generate_code() 91 | auth_store_email_code(account, code) 92 | if send_code_email(account + "@sjtu.edu.cn", code): 93 | return JsonResponse({'detail': '邮件已发送!请查看你的 SJTU 邮箱收件箱(包括垃圾邮件)。'}) 94 | except smtplib.SMTPDataError: 95 | pass 96 | return JsonResponse({'detail': '验证码发送失败,请稍后重试。'}, status=500) 97 | 98 | 99 | @api_view(['POST']) 100 | @throttle_classes([VerifyAuthRateThrottle]) 101 | @permission_classes([AllowAny]) 102 | @csrf_exempt 103 | def auth_email_verify_code(request): 104 | account: str = request.data.get("account", None) 105 | code: str = request.data.get("code", None) 106 | if account is None or code is None: 107 | return JsonResponse({'detail': '参数错误。'}, status=400) 108 | account = account.strip().lower() 109 | code = code.strip() 110 | if not auth_verify_times(account): 111 | return JsonResponse({'detail': '尝试次数达到上限,请稍后重试。'}, status=429) 112 | if not auth_verify_email_code(account, code): 113 | return JsonResponse({'detail': '验证码错误,请重试。'}, status=400) 114 | login_with(request, account) 115 | clean_email_code(account) 116 | response = JsonResponse({'account': account}) 117 | return response 118 | 119 | 120 | @api_view(['POST']) 121 | @throttle_classes([VerifyAuthRateThrottle]) 122 | @permission_classes([AllowAny]) 123 | @csrf_exempt 124 | def auth_email_verify_password(request): 125 | account = request.data.get('account') 126 | password = request.data.get('password') 127 | 128 | if account is None or password is None: 129 | return JsonResponse({'detail': '参数错误。'}, status=400) 130 | account = account.strip().lower() 131 | password = password.strip() 132 | username = hash_username(account) 133 | 134 | if not auth_verify_times(account): 135 | return JsonResponse({'detail': '尝试次数达到上限,请稍后重试。'}, status=429) 136 | 137 | user = authenticate(request, username=username, password=password) 138 | if user is None: 139 | return JsonResponse({'detail': '邮箱或者密码错误。'}, status=400) 140 | 141 | login(request, user) 142 | clean_email_code(account) 143 | return JsonResponse({'account': account}) 144 | 145 | 146 | @api_view(['POST']) 147 | @throttle_classes([EmailCodeRateThrottle]) 148 | def reset_password_send_code(request): 149 | account: str = request.data.get("account", None) 150 | if account is None: 151 | return JsonResponse({'detail': '参数错误。'}, status=400) 152 | account = account.strip().lower() 153 | if request.user.username != hash_username(account): 154 | return JsonResponse({'detail': '请输入本账号对应的邮箱!'}, status=400) 155 | try: 156 | code = generate_code() 157 | reset_store_email_code(account, code) 158 | if reset_send_code_email(account + "@sjtu.edu.cn", code): 159 | return JsonResponse({'detail': '邮件已发送!请查看你的 SJTU 邮箱收件箱(包括垃圾邮件)。'}) 160 | except smtplib.SMTPDataError: 161 | pass 162 | return JsonResponse({'detail': '验证码发送失败,请稍后重试。'}, status=500) 163 | 164 | 165 | @api_view(['POST']) 166 | @throttle_classes([VerifyAuthRateThrottle]) 167 | def reset_password_reset(request): 168 | account: str = request.data.get("account", None) 169 | code: str = request.data.get("code", None) 170 | password: str = request.data.get("password", None) 171 | if account is None or code is None or password is None: 172 | return JsonResponse({'detail': '参数错误。'}, status=400) 173 | account = account.strip().lower() 174 | if request.user.username != hash_username(account): 175 | return JsonResponse({'detail': '请输入本账号对应的邮箱!'}, status=400) 176 | if not reset_verify_email_code(account, code): 177 | return JsonResponse({'detail': '验证码错误,请重试。'}, status=400) 178 | try: 179 | validate_password(password, request.user) 180 | except ValidationError: 181 | return JsonResponse({'detail': "密码太弱!请至少9位长度,包含字母和数字,并且不是常见密码。"}, status=400) 182 | reset_clean_email_code(account) 183 | request.user.set_password(password) 184 | request.user.save() 185 | return JsonResponse({"detail": "更改密码成功。"}) 186 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Authlib==1.3.2 2 | Django==5.2.1 3 | django-cors-headers==4.6.0 4 | django-filter==24.3 5 | django-import-export==4.2.0 6 | djangorestframework==3.15.2 7 | requests==2.32.2 8 | gunicorn==23.0.0 9 | uvicorn[standard]==0.32.0 10 | psycopg[binary]==3.2.3 11 | pypinyin==0.53.0 12 | coverage==7.6.4 13 | django-debug-toolbar==4.4.6 14 | redis==5.2.0 15 | python-dotenv==1.0.1 16 | huey==2.5.2 17 | jieba==0.42.1 18 | packaging==24.1 19 | qiniu==7.15.0 20 | Pillow==11.0.0 21 | -------------------------------------------------------------------------------- /scripts/convert_yjs_json2csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | 4 | with open('../data/allcourse_yjs.json', mode='r', encoding='utf-8') as f: 5 | obj = json.load(f) 6 | courses = obj['datas'] 7 | 8 | with open("../data/yjs-data-1.csv", mode='w', encoding='utf-8-sig', newline="") as f: 9 | # 课程性质 课程代码 上课方式 课程分类 课程名称 10 | writer = csv.DictWriter(f, fieldnames=["KCXZDM", "KCDM", "SKFSDM", "KCFLDM", "KCMC", 11 | # 成绩记录方式 课程负责人姓名 开课单位 12 | "CJJLFSDM", "RKJS", "KCKKDWMC", 13 | # 上课语言 课程层次 学分 考试类型 14 | "SKYYMC", "KCCCMC", "KCXF", "KSLXDM"], 15 | extrasaction='ignore') 16 | writer.writeheader() 17 | writer.writerows(courses) 18 | -------------------------------------------------------------------------------- /scripts/download_from_yjs.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | 4 | import requests 5 | 6 | url = "http://yjs.sjtu.edu.cn/gsapp/sys/kccxapp/modules/kccx/kcxxcx.do" 7 | 8 | headers = { 9 | 'Connection': 'keep-alive', 10 | 'Cookie': '', 11 | 'DNT': '1', 12 | 'Origin': 'http://yjs.sjtu.edu.cn', 13 | 'Referer': 'http://yjs.sjtu.edu.cn/gsapp/sys/kccxapp/*default/index.do?THEME=purple&EMAP_LANG=zh', 14 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 Edg/105.0.1343.33', 15 | 'accept': 'application/json, text/javascript, */*; q=0.01', 16 | 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6', 17 | 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', 18 | 'x-requested-with': 'XMLHttpRequest' 19 | } 20 | 21 | courses = [] 22 | page = 1 23 | while page <= 5: 24 | payload = f"KKZTDM=1&querySetting=%5B%7B%22name%22%3A%22KKZTDM%22%2C%22caption%22%3A%22%E5%BC%80%E8%AF%BE%E7%8A%B6%E6%80%81%22%2C%22linkOpt%22%3A%22AND%22%2C%22builderList%22%3A%22cbl_m_List%22%2C%22builder%22%3A%22m_value_equal%22%2C%22value%22%3A%221%22%7D%5D&KCBQ=&pageSize=999&pageNumber={page}" 25 | response = requests.request("POST", url, headers=headers, data=payload) 26 | resp = response.json() 27 | print(len(resp["datas"]["kcxxcx"]["rows"])) 28 | courses = courses + resp["datas"]["kcxxcx"]["rows"] 29 | page = page + 1 30 | 31 | with open("../data/yjs-data-1.csv", mode='w', encoding='utf-8-sig', newline="") as f: 32 | # 课程性质 课程代码 上课方式 课程分类 课程名称 33 | writer = csv.DictWriter(f, fieldnames=["KCXZDM_DISPLAY", "KCDM", "SKFSDM_DISPLAY", "KCFLDM_DISPLAY", "KCMC", 34 | # 成绩记录方式 课程负责人姓名 开课单位 35 | "CJJLFSDM_DISPLAY", "KCFZRXM", "KKDW_DISPLAY", 36 | # 上课语言 课程层次 学分 考试类型 37 | "SKYYDM_DISPLAY", "KCCCDM_DISPLAY", "XF", "KSLXDM_DISPLAY"], 38 | extrasaction='ignore') 39 | writer.writeheader() 40 | writer.writerows(courses) 41 | -------------------------------------------------------------------------------- /scripts/import_csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from utils.course_data_clean import UploadData 4 | 5 | encoding = 'utf-8' 6 | data_dir = '../data' 7 | semester = '2025-2026-1' 8 | 9 | f = open(f'{data_dir}/{semester}.csv', mode='r', encoding='utf-8-sig') 10 | reader = csv.DictReader(f) 11 | 12 | data = UploadData() 13 | data.clean_data_for_jwc(reader, '.') 14 | 15 | f.close() 16 | teachers = data.get_teachers() 17 | teachers.append_col([semester] * len(teachers), header='last_semester') 18 | courses = data.get_courses() 19 | courses.append_col([semester] * len(courses), header='last_semester') 20 | print(len(teachers), len(data.departments), len(data.categories), len(courses)) 21 | 22 | with open(f'{data_dir}/Teachers.csv', mode='w', encoding=encoding, newline='') as f: 23 | f.writelines(teachers.export("csv")) 24 | 25 | with open(f'{data_dir}/Categories.csv', mode='w', encoding=encoding, newline='') as f: 26 | writer = csv.writer(f) 27 | writer.writerow(['name']) 28 | writer.writerows([[category] for category in data.categories]) 29 | 30 | with open(f'{data_dir}/Departments.csv', mode='w', encoding=encoding, newline='') as f: 31 | writer = csv.writer(f) 32 | writer.writerow(['name']) 33 | writer.writerows([[department] for department in data.departments]) 34 | 35 | with open(f'{data_dir}/Courses.csv', mode='w', encoding=encoding, newline='') as f: 36 | f.writelines(courses.export("csv")) 37 | -------------------------------------------------------------------------------- /scripts/import_from_wj.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from django.contrib.auth.models import User 4 | 5 | from jcourse_api.models import Course, Review, FormerCode, Semester 6 | 7 | f = open('./data/2021_wenjuan.csv', mode='r', encoding='utf-8-sig') 8 | csv_reader = csv.DictReader(f) 9 | q = [] 10 | users = User.objects.filter(username__istartswith='工具人') 11 | for row in csv_reader: 12 | try: 13 | code, course_name, teacher = row['课程'].split(' ') 14 | # print(code, course_name, teahcer) 15 | try: 16 | codes = [code] 17 | try: 18 | former_code = FormerCode.objects.get(old_code=code).new_code 19 | codes.append(former_code) 20 | except FormerCode.DoesNotExist: 21 | pass 22 | course = Course.objects.get(code__in=codes, main_teacher__name=teacher) 23 | has_reviewed = Review.objects.filter(course=course, user__in=users).exists() 24 | if not has_reviewed: 25 | q.append((course, row)) 26 | print(course) 27 | except Course.DoesNotExist: 28 | print("未查到:", code, course_name, teacher) 29 | except ValueError: 30 | # print(row['课程']) 31 | pass 32 | i = 1 33 | while len(q) > 0: 34 | new_q = [] 35 | user, _ = User.objects.get_or_create(username=f"工具人{i}号") 36 | for course, row in q: 37 | if Review.objects.filter(course=course, user=user).exists(): 38 | new_q.append((course, row)) 39 | continue 40 | year = int(row['学期'][0:4]) 41 | term = row['学期'][-1] 42 | if term == '春': 43 | semester = Semester.objects.get(name=f'{year - 1}-{year}-2') 44 | elif term == '夏': 45 | semester = Semester.objects.get(name=f'{year - 1}-{year}-3') 46 | else: 47 | semester = Semester.objects.get(name=f'{year}-{year + 1}-1') 48 | comment = f"课程内容:{row['课程内容']}\n上课自由度:{row['课程自由度']}\n考核标准:{row['考核标准']}\n教师:{row['教师']}" 49 | rating = int(row['安利程度']) // 2 50 | review = Review(course=course, user=user, comment=comment, rating=rating, semester=semester, score=row['成绩']) 51 | review.save() 52 | # print(review) 53 | q = new_q 54 | i = i + 1 55 | -------------------------------------------------------------------------------- /scripts/import_from_yjs.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from utils.course_data_clean import UploadData 4 | 5 | encoding = 'utf-8' 6 | data_dir = '../data' 7 | semester = '2023-2024-1' 8 | 9 | csv_gs = csv.DictReader(open(f"{data_dir}/yjs-data-1.csv", mode='r', encoding='utf-8-sig')) 10 | csv_jwc = csv.DictReader(open(f'{data_dir}/{semester}.csv', mode='r', encoding='utf-8-sig')) 11 | 12 | data = UploadData() 13 | data.clean_data_for_gs(csv_jwc, csv_gs) 14 | 15 | teachers = data.get_teachers() 16 | teachers.append_col([semester] * len(teachers), header='last_semester') 17 | courses = data.get_courses() 18 | courses.append_col([semester] * len(courses), header='last_semester') 19 | print(len(teachers), len(data.departments), len(data.categories), len(courses)) 20 | 21 | with open(f'{data_dir}/Teachers.csv', mode='w', encoding=encoding, newline='') as f: 22 | f.writelines(teachers.export("csv")) 23 | 24 | with open(f'{data_dir}/Categories.csv', mode='w', encoding=encoding, newline='') as f: 25 | writer = csv.writer(f) 26 | writer.writerow(['name']) 27 | writer.writerows([[category] for category in data.categories]) 28 | 29 | with open(f'{data_dir}/Departments.csv', mode='w', encoding=encoding, newline='') as f: 30 | writer = csv.writer(f) 31 | writer.writerow(['name']) 32 | writer.writerows([[department] for department in data.departments]) 33 | 34 | with open(f'{data_dir}/Courses.csv', mode='w', encoding=encoding, newline='') as f: 35 | f.writelines(courses.export("csv")) 36 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse_api/6a44cfe8f4cc93a14b08349387afffa7c1e401f7/utils/__init__.py -------------------------------------------------------------------------------- /utils/common.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.utils.timezone import get_current_timezone 4 | 5 | 6 | def get_time_now(): 7 | return datetime.now(tz=get_current_timezone()) 8 | -------------------------------------------------------------------------------- /utils/cut_word.py: -------------------------------------------------------------------------------- 1 | import jieba 2 | from django.db.models import Func, Value 3 | 4 | 5 | class ToTsVector(Func): 6 | function = 'to_tsvector' 7 | template = "%(function)s('english', %(expressions)s)" 8 | 9 | 10 | def cut_word(raw: str): 11 | seg_list = jieba.cut_for_search(raw) 12 | segmented_comment = " ".join(seg_list) 13 | return segmented_comment 14 | 15 | 16 | def get_cut_word_search_vector(raw: str): 17 | return ToTsVector(Value(cut_word(raw))) 18 | -------------------------------------------------------------------------------- /utils/mail.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | import ssl 3 | from email.header import Header 4 | from email.mime.multipart import MIMEMultipart 5 | from email.mime.text import MIMEText 6 | from email.utils import formataddr 7 | 8 | def send_mail_inner(sender, sender_alias, sender_pwd, recipient_list, subject, body, host, port, is_use_ssl): 9 | try: 10 | message = MIMEMultipart('alternative') 11 | message['Subject'] = Header(subject, 'UTF-8') 12 | message['From'] = formataddr([sender_alias, sender]) 13 | message['To'] = ",".join(recipient_list) 14 | to_addr_list = recipient_list 15 | 16 | mime_text = MIMEText(body) 17 | message.attach(mime_text) 18 | 19 | if is_use_ssl: 20 | context = ssl.create_default_context() 21 | context.set_ciphers('DEFAULT') 22 | client = smtplib.SMTP_SSL(host, port, context=context) 23 | else: 24 | client = smtplib.SMTP(host, port) 25 | 26 | client.login(sender, sender_pwd) 27 | client.sendmail(sender, to_addr_list, message.as_string()) 28 | client.quit() 29 | 30 | print('Send email success!') 31 | except smtplib.SMTPConnectError as e: 32 | print('Send email failed,connection error:', e.smtp_code, e.smtp_error) 33 | except smtplib.SMTPAuthenticationError as e: 34 | print('Send email failed,smtp authentication error:', e.smtp_code, e.smtp_error) 35 | except smtplib.SMTPSenderRefused as e: 36 | print('Send email failed,sender refused:', e.smtp_code, e.smtp_error) 37 | except smtplib.SMTPRecipientsRefused as e: 38 | print('Send email failed,recipients refused:', e.recipients) 39 | except smtplib.SMTPDataError as e: 40 | print('Send email failed,smtp data error:', e.smtp_code, e.smtp_error) 41 | except smtplib.SMTPException as e: 42 | print('Send email failed,smtp exception:', str(e)) 43 | except Exception as e: 44 | print('Send email failed,other error:', str(e)) 45 | --------------------------------------------------------------------------------