Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion .env.dev.sample
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export FRONTEND_URL=http://localhost:3000
export KEPLER_API_URL=http://localhost:8000/api/v1
export KEPLER_API_URL=http://localhost:8000/api/v1
export ATLAS_API_URL=http://localhost:4000
26 changes: 26 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ config :atlas, :frontend_url, System.get_env("FRONTEND_URL", "http://localhost:3

config :atlas, :kepler_api_url, System.get_env("KEPLER_API_URL", "http://localhost:8000/api/v1")

atlas_api_url =
case config_env() do
:test ->
"http://localhost:4000"

_ ->
System.get_env("ATLAS_API_URL") ||
raise """
environment variable ATLAS_API_URL is missing.
It should be the base URL of your Atlas API instance, e.g.:
http://localhost:4000 in dev
https://pombo.cesium.pt/api in prod
"""
end

config :atlas, :api_url, atlas_api_url

config :atlas,
from_email_name: System.get_env("FROM_EMAIL_NAME") || "Pombo",
from_email_address: System.get_env("FROM_EMAIL_ADDRESS") || "[email protected]"
Expand Down Expand Up @@ -110,6 +127,15 @@ if config_env() == :prod do

config :atlas, :kepler_api_url, kepler_api_url

atlas_api_url =
System.get_env("ATLAS_API_URL") ||
raise """
environment variable ATLAS_API_URL is missing.
It should be the base URL of your Atlas API instance, e.g., http://localhost:4000/api
"""

config :atlas, :api_url, atlas_api_url

# Configures CORS allowed origins
config :atlas, :allowed_origins, frontend_url

Expand Down
143 changes: 143 additions & 0 deletions lib/atlas/calendar.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
defmodule Atlas.Calendar do
@moduledoc """
iCalendar (.ics) file generator for Atlas.
"""

@prodid "-//Atlas//EN"

alias Atlas.University.Degrees.Courses.Shifts.Shift

defp format_datetime(%NaiveDateTime{} = dt) do
dt
|> NaiveDateTime.to_iso8601()
|> String.replace(~r/[-:]/, "")
|> String.replace(~r/\.\d+Z?/, "")
end

@doc """
Converts a list of shifts into `.ics` calendar content.

Each shift should be preloaded with :timeslots and :course associations.
"""
def shifts_to_ics(shifts, opts \\ []) do
uid_prefix = Keyword.get(opts, :uid_prefix, "atlas")
cal_name = Keyword.get(opts, :calendar_name, "Atlas Calendar")
dtstamp = format_datetime(NaiveDateTime.utc_now())

events =
shifts
|> Enum.with_index()
|> Enum.flat_map(fn {shift, idx} ->
shift.timeslots
|> Enum.map(fn ts ->
uid = "#{uid_prefix}-#{shift_uid(shift, idx)}-#{ts.id || "ts"}"
{dtstart, dtend} = extract_start_end(ts)

"""
BEGIN:VEVENT
UID:#{uid}
DTSTAMP:#{dtstamp}
DTSTART:#{format_datetime(dtstart)}
DTEND:#{format_datetime(dtend)}
SUMMARY:#{escape_text(build_summary(shift))}
DESCRIPTION:#{escape_text(build_description(shift, ts))}
LOCATION:#{escape_text(location_of(ts))}
RRULE:FREQ=WEEKLY;INTERVAL=1
END:VEVENT
"""
end)
end)
|> Enum.join("\r\n")

[
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:#{@prodid}",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:#{cal_name}",
events,
"END:VCALENDAR"
]
|> Enum.join("\r\n")
end

# --- Helpers ---------------------------------------------------------------

# WIP: This function is a patch for the time being as the module does not allow for proper repetiveness atm
defp extract_start_end(%{start: start_time, end: end_time, weekday: weekday}) do
today = Date.utc_today()
# 1 = Monday ... 7 = Sunday
today_weekday = Date.day_of_week(today)
target_weekday = weekday_to_int(weekday)

# Days until the next occurrence of the weekday
days_ahead = rem(target_weekday - today_weekday + 7, 7)
# always next week if today
days_ahead = if days_ahead == 0, do: 7, else: days_ahead

event_date = Date.add(today, days_ahead)

{
NaiveDateTime.new!(event_date, start_time),
NaiveDateTime.new!(event_date, end_time)
}
end

defp weekday_to_int(:monday), do: 1
defp weekday_to_int(:tuesday), do: 2
defp weekday_to_int(:wednesday), do: 3
defp weekday_to_int(:thursday), do: 4
defp weekday_to_int(:friday), do: 5
defp weekday_to_int(:saturday), do: 6
defp weekday_to_int(:sunday), do: 7

defp shift_uid(shift, idx), do: Map.get(shift, :id, idx)

defp build_summary(shift) do
course_name =
if shift.course && shift.course.name do
shift.course.name
else
"Course"
end

"#{course_name} – #{Shift.short_name(shift)}"
end

defp build_description(shift, ts) do
{st, en} = extract_start_end(ts)

time_range =
"#{String.slice(Time.to_iso8601(NaiveDateTime.to_time(st)), 0, 5)} - " <>
String.slice(Time.to_iso8601(NaiveDateTime.to_time(en)), 0, 5)

professor =
if is_binary(shift.professor), do: shift.professor, else: ""

"""
Shift #{Shift.short_name(shift)}
Time: #{time_range}
Location: #{location_of(ts)}
Professor: #{professor}
"""
end

defp location_of(ts) do
if ts.building && ts.room do
"#{ts.building} #{ts.room}"
else
"Unspecified location"
end
end

defp escape_text(nil), do: ""

defp escape_text(text) when is_binary(text) do
text
|> String.replace("\r\n", "\\n")
|> String.replace("\n", "\\n")
|> String.replace(",", "\\,")
|> String.replace(";", "\\;")
end
end
17 changes: 17 additions & 0 deletions lib/atlas/university.ex
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ defmodule Atlas.University do
Repo.get_by!(Student, number: number)
end

@doc """
Gets a single student by `user_id`.

Returns `nil` if no student exists for the given user.

## Examples

iex> get_student_by_user_id(123)
%Student{}

iex> get_student_by_user_id(999)
nil
"""
def get_student_by_user_id(user_id) do
Repo.get_by(Student, user_id: user_id)
end

@doc """
Creates a student.

Expand Down
28 changes: 28 additions & 0 deletions lib/atlas/university/degrees/courses/shifts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Atlas.University.Degrees.Courses.Shifts do
use Atlas.Context

alias Atlas.University.Degrees.Courses.Shifts.Shift
alias Atlas.University.ShiftEnrollment

@doc """
Returns the list of shifts.
Expand All @@ -21,6 +22,33 @@ defmodule Atlas.University.Degrees.Courses.Shifts do
|> Repo.all()
end

@doc """
Returns the list of shifts a student is enrolled in.

## Examples

iex> list_shifts_for_student(123)
[%Shift{}, ...]

iex> list_shifts_for_student(456)
[]

"""
def list_shifts_for_student(student_id, opts \\ []) do
shift_ids =
from(e in ShiftEnrollment,
where: e.student_id == ^student_id,
select: e.shift_id
)
|> Repo.all()

Shift
|> where([s], s.id in ^shift_ids)
|> apply_filters(opts)
|> Repo.all()
|> Repo.preload([:course, :timeslots])
end

@doc """
Gets a single shift.

Expand Down
14 changes: 12 additions & 2 deletions lib/atlas_web/controllers/auth/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ defmodule AtlasWeb.AuthController do
)
end

defp generate_token(user, session, :access) do
def generate_token(user, session, :access) do
{:ok, token, _claims} =
Guardian.encode_and_sign({user, session}, %{aud: @audience},
token_type: "access",
Expand All @@ -219,7 +219,17 @@ defmodule AtlasWeb.AuthController do
token
end

defp generate_token(user, session, :refresh) do
def generate_token(user, session, :calendar) do
{:ok, token, _claims} =
Guardian.encode_and_sign({user, session}, %{aud: @audience},
token_type: "calendar",
ttl: {10, :minute}
)

token
end

def generate_token(user, session, :refresh) do
{:ok, token, _claims} =
Guardian.encode_and_sign({user, session}, %{aud: @audience},
token_type: "refresh",
Expand Down
63 changes: 63 additions & 0 deletions lib/atlas_web/controllers/calendar_export_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule AtlasWeb.CalendarExportController do
use AtlasWeb, :controller

alias Atlas.Accounts.Guardian
alias Atlas.Calendar
alias Atlas.University
alias Atlas.University.Degrees.Courses.Shifts
alias AtlasWeb.AuthController

@audience "astra"

@doc """
Returns a short-lived signed URL to export the current user's calendar.
"""
def calendar_url(conn, _params) do
{user, session} = Guardian.Plug.current_resource(conn)

if is_nil(user) do
conn
|> put_status(:unauthorized)
|> json(%{error: "Not authenticated"})
else
token =
AuthController.generate_token(user, session, :calendar)

base_url = Application.get_env(:atlas, :api_url)

url = "#{base_url}/v1/export/student/calendar.ics?token=#{token}"

conn
|> json(%{calendar_url: url})
end
end

@doc """
Exports the current user's schedule as an `.ics` file, given a valid calendar token.
"""
def student_calendar(conn, %{"token" => token}) do
with {:ok, claims} <-
Guardian.decode_and_verify(token, %{"typ" => "calendar", "aud" => @audience}),
{:ok, {user, _session}} <- Guardian.resource_from_claims(claims),
student <- University.get_student_by_user_id(user.id),
%{} = student <- student do
shifts = Shifts.list_shifts_for_student(student.id)

ics_content =
Calendar.shifts_to_ics(shifts, calendar_name: "Student #{user.name} Schedule")

conn
|> put_resp_content_type("text/calendar; charset=utf-8")
|> put_resp_header(
"content-disposition",
~s[attachment; filename="student-#{user.name}-calendar.ics"]
)
|> send_resp(200, ics_content)
else
_ ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Invalid or expired calendar token"})
end
end
end
12 changes: 12 additions & 0 deletions lib/atlas_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ defmodule AtlasWeb.Router do
post "/reset_password", AuthController, :reset_password
end

scope "/export" do
scope "/student" do
get "/calendar.ics", CalendarExportController, :student_calendar
end
end

# Authenticated routes

pipe_through :auth
Expand Down Expand Up @@ -83,6 +89,12 @@ defmodule AtlasWeb.Router do
resources "/", ShiftExchangeRequestController, only: [:index, :create, :show, :delete]
end

scope "/export" do
scope "/student" do
get "/calendar-url", CalendarExportController, :calendar_url
end
end

pipe_through :is_at_least_professor

scope "/jobs" do
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ defmodule Atlas.MixProject do
{:igniter, "~> 0.5", only: [:dev]},
{:csv, "~> 3.2"},
{:libgraph, "~> 0.16.0"},
{:icalendar, "~> 1.1.2"},

# monitoring
{:telemetry_metrics, "~> 1.0"},
Expand Down
Loading