Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alex-budko/issue491 #501

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions Pipfile.lock

This file was deleted.

6 changes: 6 additions & 0 deletions backend/PennCourses/settings/development.py
alex-budko marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE
INTERNAL_IPS = ["127.0.0.1"]

DATABASES = {
"default": dj_database_url.config(
default="sqlite:///" + os.path.join(BASE_DIR, "db.sqlite3"), conn_max_age=20
)
}

CSRF_TRUSTED_ORIGINS = ["http://localhost:3000"]

CACHES = {
Expand Down
3 changes: 3 additions & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ scikit-learn = "*"
pandas = "*"
python-dateutil = "*"
docutils = "*"
ics = "*"
psycopg2-binary = "*"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we add this under dev packages at the very least?

drf-nested-routers = "*"

[requires]
python_full_version = "3.10"
966 changes: 723 additions & 243 deletions backend/Pipfile.lock

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Welcome to the Penn Courses Backend (PCX)!

### Prerequisites

- Python 3.10 ([`pyenv`](https://github.com/pyenv/pyenv) is recommended)
- [`pipenv`](https://pipenv.pypa.io/en/latest/)
- [`docker` and `docker-compose`](https://docs.docker.com/get-docker/)
- Python 3.10 ([`pyenv`](https://github.com/pyenv/pyenv) is recommended)
- [`pipenv`](https://pipenv.pypa.io/en/latest/)
- [`docker` and `docker-compose`](https://docs.docker.com/get-docker/)
Expand All @@ -30,8 +33,8 @@ NOTE: when using `pipenv`, environment variables are only refreshed when you exi
- `echo 'export PATH="/usr/local/opt/openssl@3/bin:$PATH"' >> ~/.zshrc`
- `export LDFLAGS="-L/usr/local/opt/openssl@3/lib"`
- `export CPPFLAGS="-I/usr/local/opt/openssl@3/include"`
- **Windows/Linux**
1. `apt-get install gcc python3-dev libpq-dev`
- Windows (WSL) or Linux:
- `apt-get install gcc python3-dev libpq-dev`

3. Running Docker
1. Open a new terminal window (also in the `backend` directory) and run `docker-compose up`
Expand Down Expand Up @@ -66,6 +69,7 @@ NOTE: when using `pipenv`, environment variables are only refreshed when you exi
- With the backend server running, you can also run the frontend for any of our PCX products by following the instructions in the `frontend` README.
- Note: If you have not loaded the test data from the previous step (Step 4), ensure that you have created a local user named "Penn-Courses" with the password "postgres" in your PostgreSQL. To add the user, navigate to your pgAdmin, and follow the path of Object -> Create -> Login/Group Role and create the appropriate user.

7. Running tests
7. Running tests
- Run `python manage.py test` to run our test suite.
- To run a specific test, you can use the format `python manage.py test tests.review.test_api.OneReviewTestCase.test_course` (also note that in this example, you can use any prefix of that path to run a larger set of tests).
Expand All @@ -77,7 +81,7 @@ NOTE: when using `pipenv`, environment variables are only refreshed when you exi
- This will spin up a container from which you can run the server (with all required packages preinstalled).
3. In a separate terminal (from any directory), run `docker exec -it backend-development-1 /bin/bash` to open a shell in the container (if this says "no such container", run `docker container ls` and use the name of whatever container most closely matches the `backend_development` image). Just like exiting a Pipenv shell, you can exit the container by pressing `Ctrl+D` (which sends the "end of transmission" / EOF character).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The numbering is off here, should be 2

- You might want to add an alias for this command so it is easier to run (e.g. `echo 'alias courses-backend="docker exec -it backend_development_1 /bin/bash"' >> ~/.zshrc && source ~/.zshrc`). Then you can just run `courses-backend` from any directory to connect to the Docker container from which you will run the server (assuming `courses-compose` is already running in another terminal).
4. Once you have a shell open in the container, you can continue running the rest of the commands in the [Setting Up the Backend](#setting-up-the-backend) section of this README (except you can skip `pipenv install --dev` since that has already been done for you).
4. Once you have a shell open in the container, you can continue running the rest of the commands in this README (except you can skip `pipenv install --dev` since that has already been done for you).
1. Remember to run `pipenv shell` (to open a [Pipenv] shell inside of a [docker container] shell inside of your computer's shell!). Note that the `/backend` directory inside the container is automatically synced with the `backend` directory on your host machine (from which you ran `docker-compose --profile=dev up`).
5. There's just one last complication. Due to some annoying details of Docker networking, you have to expose the server on IP address `0.0.0.0` inside the container, rather than `127.0.0.1` or `localhost` as is default (otherwise the server won't be accessible from outside of the container). To do this, instead of running `python manage.py runserver`, run `python manage.py runserver 0.0.0.0:8000`. In `Dockerfile.dev`, we automatically alias the command `runserver` to the latter, so in the container shell (in `/backend`, as is default) you can simply run the command `runserver` (no `python manage.py` necessary).

Expand Down
8 changes: 4 additions & 4 deletions backend/alert/management/commands/recomputestats.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ def recompute_has_reviews():
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE "courses_section" U0
UPDATE "courses_section" AS U0
SET "has_reviews" = CASE WHEN
EXISTS (SELECT id FROM "review_review" U1
EXISTS (SELECT id FROM "review_review" AS U1
WHERE U0."id" = U1."section_id")
THEN true ELSE false
END
Expand All @@ -67,9 +67,9 @@ def recompute_has_status_updates():
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE "courses_section" U0
UPDATE "courses_section" AS U0
SET "has_status_updates" = CASE WHEN
EXISTS (SELECT id FROM "courses_statusupdate" U1
EXISTS (SELECT id FROM "courses_statusupdate" AS U1
WHERE U0."id" = U1."section_id")
THEN true ELSE false
END
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.1.5 on 2023-01-15 16:39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we merge this migration with the migrations on master?


import uuid

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("courses", "0053_alter_ngssrestriction_code"),
]

operations = [
migrations.AddField(
model_name="userprofile",
name="uuid_secret",
field=models.UUIDField(default=uuid.uuid4),
),
migrations.AlterField(
model_name="ngssrestriction",
name="inclusive",
field=models.BooleanField(
help_text='\nWhether this is an include or exclude restriction. Corresponds to the `incl_excl_ind`\nresponse field. `True` if include (ie, `incl_excl_ind` is "I") and `False`\nif exclude ("E").\n'
),
),
]
1 change: 1 addition & 0 deletions backend/courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,7 @@ class UserProfile(models.Model):
email = models.EmailField(
blank=True, null=True, help_text="The email of the User. Defaults to null."
)
uuid_secret = models.UUIDField(default=uuid.uuid4)
push_notifications = models.BooleanField(
default=False,
help_text=dedent(
Expand Down
6 changes: 3 additions & 3 deletions backend/plan/urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from django.urls import include, path
from django.views.generic import TemplateView
from rest_framework import routers
from rest_framework_nested import routers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait why do we use rest_framework_nested here?


from plan.views import ScheduleViewSet, recommend_courses_view
from plan.views import CalendarAPIView, ScheduleViewSet, recommend_courses_view


router = routers.DefaultRouter()
router.register(r"schedules", ScheduleViewSet, basename="schedules")


urlpatterns = [
path("<int:schedule_pk>/calendar/", CalendarAPIView.as_view(), name="calendar-view"),
path("", TemplateView.as_view(template_name="plan/build/index.html")),
path("recommendations/", recommend_courses_view, name="recommend-courses"),
path("", include(router.urls)),
Expand Down
107 changes: 106 additions & 1 deletion backend/plan/views.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import arrow
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError
from django.db.models import Prefetch, Q
from django.http import HttpResponse
from django.utils import timezone
from django_auto_prefetching import AutoPrefetchViewSetMixin
from ics import Calendar as ICSCal
from ics import Event as ICSEvent
from ics.grammar.parse import ContentLine
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes, schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from courses.models import Course, Section
from courses.models import Course, Meeting, Section
from courses.serializers import CourseListSerializer
from courses.util import get_course_and_section, get_current_semester
from PennCourses.docs_settings import PcxAutoSchema, reverse_func
Expand Down Expand Up @@ -399,3 +406,101 @@ def get_queryset(self):
"sections__meetings__room",
)
return queryset


class CalendarAPIView(APIView):
schema = PcxAutoSchema(
response_codes={
reverse_func("calendar-view", args=["schedule_pk"]): {
"GET": {
200: "Schedule exported successfully",
},
},
},
)

def get(self, *args, **kwargs):
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we usually document this information in PcxAutoSchema -- see here for an example.

Return a .ics file of the user's selected schedule
---
responses:
"200":
description: Return a calendar file in ICS format.
content:
text/calendar:
schema:
type: string
---
"""
schedule_pk = kwargs["schedule_pk"]

schedule = (
Schedule.objects.filter(pk=schedule_pk)
.prefetch_related("sections", "sections__meetings")
.first()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we should use .get() I think since we only expect one here

)

if not schedule:
return Response({"detail": "Invalid schedule"}, status=status.HTTP_403_FORBIDDEN)

day_mapping = {"M": "MO", "T": "TU", "W": "WE", "R": "TH", "F": "FR"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably better practice to just declare this at the top level (not inside the function) and make it all caps


calendar = ICSCal(creator="Penn Labs")
calendar.extra.append(ContentLine(name="X-WR-CALNAME", value=f"{schedule.name} Schedule"))

for section in schedule.sections.all():
e = ICSEvent()
e.name = section.full_code
e.created = timezone.now()

days = []
for meeting in section.meetings.all():
days.append(day_mapping[meeting.day])
first_meeting = list(section.meetings.all())[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is any sorted order intended here?


start_time = str(Meeting.int_to_time(first_meeting.start))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a general question -- is there a reason we do this from the backend instead of the frontent?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I first built the feature I had all the logic done on the frontend, but Rohan suggested that logic be moved to the backend to simplify the frontend code and make it look more visually appealing

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok yeah I think the link is maybe more intuitive.

end_time = str(Meeting.int_to_time(first_meeting.end))

if not start_time:
start_time = ""
if not end_time:
end_time = ""

if first_meeting.start_date is None:
start_datetime = ""
end_datetime = ""
else:
start_datetime = first_meeting.start_date + " "
end_datetime = first_meeting.start_date + " "

if int(first_meeting.start) < 10:
start_datetime += "0"
if int(first_meeting.end) < 10:
end_datetime += "0"

start_datetime += start_time
end_datetime += end_time

e.begin = arrow.get(
start_datetime, "YYYY-MM-DD HH:mm A", tzinfo="America/New York"
).format("YYYYMMDDTHHmmss")
e.end = arrow.get(end_datetime, "YYYY-MM-DD HH:mm A", tzinfo="America/New York").format(
"YYYYMMDDTHHmmss"
)
end_date = arrow.get(
first_meeting.end_date, "YYYY-MM-DD", tzinfo="America/New York"
).format("YYYYMMDDTHHmmss")

e.extra.append(
ContentLine(
"RRULE",
{},
f'FREQ=WEEKLY;UNTIL={end_date}Z;WKST=SU;BYDAY={",".join(days)}',
)
)

calendar.events.add(e)

response = HttpResponse(calendar, content_type="text/calendar")
response["Content-Disposition"] = "attachment; pcp-schedule.ics"
return response
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@
"hooks": {
"pre-commit": "lerna run --concurrency 1 precommit"
}
},
"dependencies": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to have a dependencies obj?

}
}
6 changes: 6 additions & 0 deletions frontend/plan/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const REMOVE_SCHED_ITEM = "REMOVE_SCHED_ITEM";
export const DELETE_SCHEDULE = "DELETE_SCHEDULE";
export const RENAME_SCHEDULE = "RENAME_SCHEDULE";
export const DUPLICATE_SCHEDULE = "DUPLICATE_SCHEDULE";
export const DOWNLOAD_SCHEDULE = "DOWNLOAD_SCHEDULE";
export const CLEAR_SCHEDULE = "CLEAR_SCHEDULE";
export const ENFORCE_SEMESTER = "ENFORCE_SEMESTER";
export const CLEAR_ALL_SCHEDULE_DATA = "CLEAR_ALL_SCHEDULE_DATA";
Expand Down Expand Up @@ -82,6 +83,11 @@ export const duplicateSchedule = (scheduleName) => ({
scheduleName,
});

export const downloadSchedule = (scheduleName) => ({
type: DOWNLOAD_SCHEDULE,
scheduleName,
});

export const deleteSchedule = (scheduleName) => ({
type: DELETE_SCHEDULE,
scheduleName,
Expand Down
Loading
Loading