Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@
coverage/
rsa_keys.yml
pg_data/
backend_instructions.txt
30 changes: 16 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
FROM ruby:3.4.5

LABEL maintainer="Ankur Mundra <ankurmundra0212@gmail.com>"
# Install dependencies
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Revisions to shared files like this are likely going to lead to conflicts and reduce the chance of this PR ever getting merged.


RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y netcat-openbsd
apt-get install -y --no-install-recommends \
build-essential \
curl \
default-libmysqlclient-dev \
netcat-openbsd \
pkg-config && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*

# Set the working directory
WORKDIR /app

# Copy your application files from current location to WORKDIR
COPY . .
COPY Gemfile Gemfile.lock ./
RUN gem install bundler:2.4.14 && bundle install

# Install Ruby dependencies
RUN gem update --system && gem install bundler:2.4.7
RUN bundle install
COPY . .

EXPOSE 3002
EXPOSE 3002

# Set the entry point
ENTRYPOINT ["/app/setup.sh"]
ENTRYPOINT ["/app/setup.sh"]
CMD ["bin/rails", "server", "-p", "3002", "-b", "0.0.0.0"]
88 changes: 71 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,83 @@
# Expertiza Backend Re-Implementation

This README would normally document whatever steps are necessary to get the
application up and running.
Rails API backend for the Expertiza reimplementation project.

Things you may want to cover:
## Stack

* Ruby version - 3.4.5
- Ruby `3.4.5`
- Rails `8.0`
- MySQL `8.0`
- Redis
- Docker Compose

## Development Environment
## Run With Docker

### Prerequisites
- Verify that [Docker Desktop](https://www.docker.com/products/docker-desktop/) is installed and running.
- [Download](https://www.jetbrains.com/ruby/download/) RubyMine
- Make sure that the Docker plugin [is enabled](https://www.jetbrains.com/help/ruby/docker.html#enable_docker).

- Docker Desktop installed and running
- Docker Compose available as `docker compose`

### Instructions
Tutorial: [Docker Compose as a remote interpreter](https://www.jetbrains.com/help/ruby/using-docker-compose-as-a-remote-interpreter.html)
### Start the app

### Video Tutorial
```bash
docker compose up --build
```

<a href="http://www.youtube.com/watch?feature=player_embedded&v=BHniRaZ0_JE
" target="_blank"><img src="http://img.youtube.com/vi/BHniRaZ0_JE/maxresdefault.jpg"
alt="IMAGE ALT TEXT HERE" width="560" height="315" border="10" /></a>
This starts:

### Database Credentials
- username: root
- password: expertiza
- `app` on `http://localhost:3002`
- MySQL on host port `3307`
- Redis on host port `6380`

On startup the app container will:

1. wait for MySQL to become healthy
2. run `bin/rails db:create` and `bin/rails db:migrate`
3. start Rails on port `3002`

The database is not seeded by default. To seed it once during startup:

```bash
SEED_DB=true docker compose up --build
```

Use seeding carefully: the current seed file is sample-data oriented and is not intended to run on every restart.

## Database Access

### App database settings inside Docker

- host: `db`
- port: `3306`
- username: `root`
- password: `expertiza`
- development database: `reimplementation_development`
- test database: `reimplementation_test`
- production database: `reimplementation_production`

### Connect from the host machine

```bash
mysql -h 127.0.0.1 -P 3307 -u root -pexpertiza reimplementation_development
```

### Connect through the container

```bash
docker compose exec db mysql -uroot -pexpertiza reimplementation_development
```

### Useful database commands

```bash
docker compose exec app bin/rails db:create
docker compose exec app bin/rails db:migrate
docker compose exec app bin/rails db:seed
docker compose exec app bin/rails dbconsole
```

## Notes

- MySQL data is persisted in the `expertiza-mysql` Docker volume.
- Redis data is persisted in the `expertiza-redis` Docker volume.
- If you run Rails outside Docker, point it at the Dockerized MySQL instance with `DB_HOST=127.0.0.1` and `DB_PORT=3307`.
91 changes: 91 additions & 0 deletions app/controllers/revision_requests_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

class RevisionRequestsController < ApplicationController
prepend_before_action :set_assignment, only: :index
prepend_before_action :set_revision_request, only: %i[show update]

def action_allowed?
case params[:action]
when 'index'
current_user_has_instructor_privileges? && current_user_instructs_assignment?(@assignment)
when 'show'
return false unless @revision_request

owns_revision_request? || current_user_instructs_assignment?(@revision_request.assignment)
when 'update'
return false unless @revision_request

current_user_has_instructor_privileges? && current_user_instructs_assignment?(@revision_request.assignment)
else
false
end
end

def index
return if invalid_status_filter?

revision_requests = RevisionRequest.where(assignment_id: @assignment.id)
revision_requests = revision_requests.where(status: params[:status]) if params[:status].present?

render json: revision_requests.order(created_at: :desc).map(&:as_json), status: :ok
end

def show
return unless @revision_request

render json: @revision_request.as_json, status: :ok
end

def update
return render json: { error: 'This revision request has already been processed' }, status: :unprocessable_entity unless @revision_request.status == RevisionRequest::PENDING
return if invalid_update_status?

if @revision_request.update(update_params)
render json: @revision_request.as_json, status: :ok
else
render json: { error: @revision_request.errors.full_messages.to_sentence }, status: :unprocessable_entity
end
end

private

def set_assignment
@assignment = Assignment.find_by(id: params[:assignment_id])
return if @assignment

render json: { error: 'Assignment not found' }, status: :not_found
end

def set_revision_request
@revision_request = RevisionRequest.find_by(id: params[:id])
return if @revision_request

render json: { error: 'Revision request not found' }, status: :not_found
end

def owns_revision_request?
@revision_request.participant.user_id == current_user.id
end

def invalid_status_filter?
return false if params[:status].blank? || RevisionRequest::STATUSES.include?(params[:status])

render json: { error: 'Status must be PENDING, APPROVED, or DECLINED' }, status: :unprocessable_entity
true
end

def invalid_update_status?
return false if valid_resolved_status?

render json: { error: 'Status must be APPROVED or DECLINED' }, status: :unprocessable_entity
true
end

def valid_resolved_status?
[RevisionRequest::APPROVED, RevisionRequest::DECLINED].include?(update_params[:status])
end

def update_params
params.require(:revision_request).permit(:status, :response_comment)
end
end
48 changes: 36 additions & 12 deletions app/controllers/student_tasks_controller.rb
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
class StudentTasksController < ApplicationController
before_action :set_student_task, only: %i[show view request_revision]

# List retrieves all student tasks associated with the current logged-in user.
def action_allowed?
current_user_has_student_privileges?
end

def index
list
end

def list
# Retrieves all tasks that belong to the current user.
@student_tasks = StudentTask.from_user(current_user)
# Render the list of student tasks as JSON.
render json: @student_tasks, status: :ok
render json: StudentTask.from_user(current_user), status: :ok
end

def show
render json: @student_task, status: :ok
end

# The view function retrieves a student task based on a participant's ID.
# It is meant to provide an endpoint where tasks can be queried based on participant ID.
def view
# Retrieves the student task where the participant's ID matches the provided parameter.
# This function will be used for clicking on a specific student task to "view" its details.
@student_task = StudentTask.from_participant_id(params[:id])
# Render the found student task as JSON.
render json: @student_task, status: :ok
show
end

def request_revision
return render json: { error: 'Revision requests require a team submission' }, status: :unprocessable_entity unless @student_task.team
return render json: { error: 'Revision requests are not available for this task' }, status: :unprocessable_entity unless @student_task.can_request_revision

revision_request = RevisionRequest.new(
participant: @participant,
team: @student_task.team,
assignment: @participant.assignment,
comments: params[:comments]
)

if revision_request.save
@student_task = StudentTask.from_participant(@participant)
render json: { message: 'Revision request submitted successfully', revision_request: revision_request.as_json, student_task: @student_task.as_json }, status: :created
else
render json: { error: revision_request.errors.full_messages.to_sentence }, status: :unprocessable_entity
end
end

private

def set_student_task
@participant = AssignmentParticipant.find_by(id: params[:id])
return render json: { error: 'Student task not found' }, status: :not_found unless @participant
return render json: { error: 'You are not authorized to access this student task' }, status: :forbidden unless @participant.user_id == current_user.id

@student_task = StudentTask.from_participant(@participant)
end
end
1 change: 1 addition & 0 deletions app/models/assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Assignment < ApplicationRecord
has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy
has_many :assignments_duties, dependent: :destroy
has_many :duties, through: :assignments_duties
has_many :revision_requests, dependent: :destroy
belongs_to :course, optional: true
belongs_to :instructor, class_name: 'User', inverse_of: :assignments

Expand Down
1 change: 1 addition & 0 deletions app/models/assignment_participant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class AssignmentParticipant < Participant
has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id'
has_many :response_maps, foreign_key: 'reviewee_id'
has_many :sent_invitations, class_name: 'Invitation', foreign_key: 'from_id'
has_many :revision_requests, foreign_key: 'participant_id', dependent: :destroy
belongs_to :duty, optional: true
belongs_to :user
validates :handle, presence: true
Expand Down
3 changes: 2 additions & 1 deletion app/models/assignment_team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class AssignmentTeam < Team
has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id'
has_many :review_response_maps, foreign_key: 'reviewee_id'
has_many :responses, through: :review_response_maps, foreign_key: 'map_id'
has_many :revision_requests, foreign_key: 'team_id', dependent: :destroy

# Delegation to avoid Law of Demeter violations
delegate :path, to: :assignment, prefix: true
Expand Down Expand Up @@ -224,4 +225,4 @@ def validate_assignment_team_type
end
end

class TeamFullError < StandardError; end
class TeamFullError < StandardError; end
40 changes: 40 additions & 0 deletions app/models/revision_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

class RevisionRequest < ApplicationRecord
PENDING = 'PENDING'
APPROVED = 'APPROVED'
DECLINED = 'DECLINED'
STATUSES = [PENDING, APPROVED, DECLINED].freeze

belongs_to :participant, class_name: 'AssignmentParticipant'
belongs_to :team, class_name: 'AssignmentTeam'
belongs_to :assignment

validates :comments, presence: true
validates :status, inclusion: { in: STATUSES }
validate :one_pending_request_per_participant_team, on: :create

scope :pending, -> { where(status: PENDING) }

def as_json(_options = {})
{
id: id,
participant_id: participant_id,
team_id: team_id,
assignment_id: assignment_id,
status: status,
comments: comments,
response_comment: response_comment,
created_at: created_at&.iso8601,
updated_at: updated_at&.iso8601
}
end

private

def one_pending_request_per_participant_team
return unless self.class.pending.exists?(participant_id: participant_id, team_id: team_id)

errors.add(:base, 'A pending revision request already exists for this task')
end
end
Loading
Loading