diff --git a/2 b/2 new file mode 100644 index 000000000..e69de29bb diff --git a/app/controllers/api/v1/submission_records_controller.rb b/app/controllers/api/v1/submission_records_controller.rb new file mode 100644 index 000000000..9b6352920 --- /dev/null +++ b/app/controllers/api/v1/submission_records_controller.rb @@ -0,0 +1,94 @@ +class SubmissionRecordsController < ApplicationController + include AuthorizationHelper + + # Set up before_action callbacks + before_action :set_submission_record, only: %i[show edit update destroy] + before_action :set_assignment_team, only: [:index] + rescue_from ActiveRecord::RecordNotFound, with: :not_found + + # Determines if the current user has permission to access submission records + # Returns true for: + # - Administrators + # - Instructors teaching the assignment + # - TAs assigned to the assignment + # - Students who are members of the team + def action_allowed? + # Allow access to instructors, TAs, and admins + return true if current_user_has_admin_privileges? + return true if current_user_has_instructor_privileges? && current_user_instructs_assignment?(@assignment) + return true if current_user_has_ta_privileges? && current_user_has_ta_mapping_for_assignment?(@assignment) + + # Allow students to view their own team's submission records + if current_user_has_student_privileges? && (@assignment_team.user_id == current_user.id || @assignment_team.users.include?(current_user)) + return true + end + + false + end + + # Displays submission records for a specific assignment team + # - Fetches all records for the team + # - Orders them by most recent first + # - Makes them available to the view as @submission_records + def index + @submission_records = SubmissionRecord.where(team_id: @assignment_team.id) + .order(created_at: :desc) # Order by most recent first + render json: @submission_records, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + # GET /submission_records/:id + def show + render json: @submission_record, status: :ok + end + + # POST /submission_records + def create + @submission_record = SubmissionRecord.new(submission_record_params) + if @submission_record.save + render json: @submission_record, status: :created + else + render json: @submission_record.errors, status: :unprocessable_entity + end + end + + # PATCH/PUT /submission_records/:id + def update + if @submission_record.update(submission_record_params) + render json: @submission_record, status: :ok + else + render json: @submission_record.errors, status: :unprocessable_entity + end + end + + # DELETE /submission_records/:id + def destroy + @submission_record.destroy + render json: { message: 'Submission record deleted successfully' }, status: :no_content + end + + private + + # Sets up the assignment team and assignment for the current request + # Used by the index action to ensure proper context for viewing records + def set_assignment_team + @assignment_team = AssignmentTeam.find(params[:team_id]) + @assignment = @assignment_team.parent + rescue ActiveRecord::RecordNotFound + render json: { error: 'Assignment team not found' }, status: :not_found + end + + # Sets up a single submission record for show/edit/update/destroy actions + def set_submission_record + @submission_record = SubmissionRecord.find(params[:id]) + end + + def submission_record_params + params.require(:submission_record).permit(:team_id, :operation, :user, :content, :created_at) + end + + def not_found + render json: { error: 'Submission record not found' }, status: :not_found + end +end diff --git a/app/models/submission_record.rb b/app/models/submission_record.rb new file mode 100644 index 000000000..6ab3a7d2e --- /dev/null +++ b/app/models/submission_record.rb @@ -0,0 +1,20 @@ +# Represents a record of a submission made by a student team +# This model tracks all submission activities +class SubmissionRecord < ApplicationRecord + # Associations + belongs_to :user + belongs_to :team, class_name: 'AssignmentTeam' + belongs_to :assignment + + # Validations + validates :content, presence: true + validates :operation, presence: true + validates :team_id, presence: true + validates :user, presence: true + validates :assignment_id, presence: true + + # Scopes for common queries + scope :recent, -> { order(created_at: :desc) } + scope :for_team, ->(team_id) { where(team_id: team_id) } + scope :for_assignment, ->(assignment_id) { where(assignment_id: assignment_id) } +end \ No newline at end of file diff --git a/app/views/student_task/list.html.erb.html b/app/views/student_task/list.html.erb.html new file mode 100644 index 000000000..53106cd3a --- /dev/null +++ b/app/views/student_task/list.html.erb.html @@ -0,0 +1,154 @@ +
<%=t ".assignment_name" %> | +<%=t ".course" %> | +<%=t ".topic" %> | +<%=t ".current_stage" %> | +<%=t ".review_grade" %> | +<%=t ".badges" %> | +<%=t ".stage_deadline" %>![]() |
+ <%=t ".submission_history" %> | +<%=t ".publishing_rights" %>![]() |
+ |
---|---|---|---|---|---|---|---|---|---|
<%= link_to student_task.assignment.name, :action => 'view', :id => participant %> | + +<%= student_task.course.try :name %> | + + <% topic_id = SignedUpTeam.topic_id(participant.parent_id, participant.user_id) %> + <% if SignUpTopic.exists?(topic_id) %> +<%= SignUpTopic.find(topic_id).try :topic_name %> | + <% else %> +- | + <% end %> + ++ <% if participant.assignment.link_for_current_stage(topic_id)!= nil && participant.assignment.link_for_current_stage(topic_id).length!=0%> + <%= link_to participant.assignment.current_stage_name(topic_id), participant.assignment.link_for_current_stage(topic_id) %> + <% else %> + <%= participant.assignment.current_stage_name(topic_id) %> + <% end %> + | + + +<%= get_review_grade_info(participant) %> | + +<%= get_awarded_badges(participant) %> | + +<%= student_task.stage_deadline.in_time_zone(session[:user].timezonepref) %> | + ++ <%= link_to "Submission History", submission_records_path(team_id: participant.team.id) %> + | + + ++ checked<%end%>> + | + +
Submission ID | +Submitted By | +Submission Time | +Content | +
---|---|---|---|
<%= record.id %> | +<%= record.user.try(:name) || "Unknown" %> | +<%= record.created_at.strftime("%Y-%m-%d %H:%M:%S") %> | +<%= truncate(record.content, length: 50) %> | +
No submission records found for this team.
+<% end %> diff --git a/config/database.yml b/config/database.yml index b9f5aa055..092986fc7 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,18 +1,55 @@ +# MySQL. Versions 5.5.8 and up are supported. +# +# Install the MySQL driver +# gem install mysql2 +# +# Ensure the MySQL gem is defined in your Gemfile +# gem "mysql2" +# +# And be sure to use new-style password hashing: +# https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html +# default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - port: 3306 - socket: /var/run/mysqld/mysqld.sock + username: root + password: expertiza + development: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> + database: reimplementation_development +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> + database: reimplementation_test +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="mysql2://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# production: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_production + username: reimplementation + password: <%= ENV["REIMPLEMENTATION_DATABASE_PASSWORD"] %> diff --git a/config/routes.rb b/config/routes.rb index 33df803e7..8f7165fda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,4 @@ Rails.application.routes.draw do - mount Rswag::Api::Engine => 'api-docs' mount Rswag::Ui::Engine => 'api-docs' # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html @@ -23,24 +22,32 @@ get 'role/:name', action: :role_users end end + + resources :submission_records, only: %i[create update destroy] do + collection do + get 'index/:team_id', to: 'submission_records#index', as: 'team_submission_records' + end + end + + resources :submission_records, only: [:show] resources :assignments do collection do - post '/:assignment_id/add_participant/:user_id',action: :add_participant - delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant - patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course - patch '/:assignment_id/assign_course/:course_id',action: :assign_course + post '/:assignment_id/add_participant/:user_id', action: :add_participant + delete '/:assignment_id/remove_participant/:user_id', action: :remove_participant + patch '/:assignment_id/remove_assignment_from_course', action: :remove_assignment_from_course + patch '/:assignment_id/assign_course/:course_id', action: :assign_course post '/:assignment_id/copy_assignment', action: :copy_assignment - get '/:assignment_id/has_topics',action: :has_topics - get '/:assignment_id/show_assignment_details',action: :show_assignment_details + get '/:assignment_id/has_topics', action: :has_topics + get '/:assignment_id/show_assignment_details', action: :show_assignment_details get '/:assignment_id/team_assignment', action: :team_assignment get '/:assignment_id/has_teams', action: :has_teams get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? - post '/:assignment_id/create_node',action: :create_node + post '/:assignment_id/create_node', action: :create_node end end - resources :bookmarks, except: [:new, :edit] do + resources :bookmarks, except: %i[new edit] do member do get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' @@ -72,8 +79,8 @@ resources :questions do collection do get :types - get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' - delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' + get 'show_all/questionnaire/:id', to: 'questions#show_all#questionnaire', as: 'show_all' + delete 'delete_all/questionnaire/:id', to: 'questions#delete_all#questionnaire', as: 'delete_all' end end @@ -86,12 +93,10 @@ resources :join_team_requests do collection do - post 'decline/:id', to:'join_team_requests#decline' + post 'decline/:id', to: 'join_team_requests#decline' end end - - resources :sign_up_topics do collection do get :filter @@ -122,4 +127,4 @@ end end end -end \ No newline at end of file +end diff --git a/spec/requests/api/v1/submission_record_spec.rb b/spec/requests/api/v1/submission_record_spec.rb new file mode 100644 index 000000000..be2b6c096 --- /dev/null +++ b/spec/requests/api/v1/submission_record_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true +require 'rails_helper' +require 'swagger_helper' + +RSpec.describe 'Submission Records API', type: :request do + let(:valid_headers) { { "Authorization" => "Bearer valid_token" } } + let(:invalid_headers) { { "Authorization" => "Bearer invalid_token" } } + + before(:all) do + @roles = create_roles_hierarchy + end + + let(:student) do + User.create!( + name: 'StudentA', + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student A", + email: "studenta@example.com", + ) + end + + let(:instructor) do + User.create!( + name: "Instructor", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor Name", + email: "instructor@example.com" + ) + end + + let!(:assignment) do + Assignment.create!( + name: 'Test Assignment', + instructor_id: instructor.id + ) + end + + let!(:team) do + Team.create!( + name: 'Team A', + assignment: assignment, + users: [student], + parent_id: 1 + ) + end + + let!(:submission_record) do + SubmissionRecord.create!( + team_id: team.id, + assignment_id: assignment.id, + user: student, + operation: "submit", + content: "Test submission" + ) + end + + let(:token) { JsonWebToken.encode({ id: student.id }) } + let(:Authorization) { "Bearer #{token}" } + + path '/submission_records/{id}' do + get 'Retrieve a Submission Record' do + tags 'Submission Records' + produces 'application/json' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Submission Record ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer Token' + + response '200', 'submission record found' do + let(:id) { submission_record.id } + run_test! + end + + response '404', 'submission record not found' do + let(:id) { 9999999 } + run_test! + end + + response '500', 'internal server error' do + before do + allow(SubmissionRecord).to receive(:find).and_raise(StandardError) + end + let(:id) { submission_record.id } + run_test! + end + end + end + + path '/submission_records' do + post 'Create a Submission Record' do + tags 'Submission Records' + consumes 'application/json' + produces 'application/json' + parameter name: :submission_record, in: :body, schema: { + type: :object, + properties: { + team_id: { type: :integer }, + assignment_id: { type: :integer }, + user_id: { type: :integer }, + operation: { type: :string }, + content: { type: :string } + } + } + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer Token' + + response '201', 'submission record created' do + let(:submission_record) do + { + team_id: team.id, + assignment_id: assignment.id, + user_id: student.id, + operation: "submit", + content: "New submission" + } + end + run_test! + end + + response '400', 'bad request - missing parameters' do + let(:submission_record) { { operation: "submit" } } # Missing required fields + run_test! + end + end + end + + path '/submission_records/{id}' do + delete 'Delete a Submission Record' do + tags 'Submission Records' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Submission Record ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer Token' + + response '204', 'submission record deleted' do + let(:id) { submission_record.id } + run_test! + end + + response '404', 'submission record not found' do + let(:id) { 9999999 } + run_test! + end + + response '403', 'forbidden - student cannot delete' do + let(:Authorization) { "Bearer #{JsonWebToken.encode({ id: other_student.id })}" } + let(:id) { submission_record.id } + run_test! + end + + response '401', 'unauthorized - invalid token' do + let(:Authorization) { "Bearer invalid_token" } + let(:id) { submission_record.id } + run_test! + end + + response '503', 'catch all' do + before do + allow(SubmissionRecord).to receive(:find).and_raise(ActiveRecord::ConnectionNotEstablished) + end + let(:id) { submission_record.id } + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/submission_records_controller_spec.rb b/spec/requests/api/v1/submission_records_controller_spec.rb new file mode 100644 index 000000000..8b1f8ee3d --- /dev/null +++ b/spec/requests/api/v1/submission_records_controller_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true +require 'rails_helper' +require 'swagger_helper' +require 'json_web_token' + +# spec/requests/api/v1/submission_records_spec.rb + +#INITIALIZE TESTING OBJECTS +RSpec.describe 'Submission Records API', type: :request do + let(:valid_headers) { { "Authorization" => "Bearer #{JsonWebToken.encode({ id: studenta.id })}" } } + let(:invalid_headers) { { "Authorization" => "Bearer invalid_token" } } + let(:unauthorized_headers) { { "Authorization" => "Bearer #{JsonWebToken.encode({ id: studentb.id })}" } } + #call roles hierarchy to create sample roles + before(:all) do + @roles = create_roles_hierarchy + end + + #OBJECTIVE: Create appropriate structures for student functionality testing. + # Two different students will be created and one assignment associated with one of those students + # The assignment will have a designated team and associated submission record + + let(:studenta) do + User.create!( + name: 'StudentA', + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student A", + email: "studenta@example.com", + ) + end + + let(:studentb) do + User.create!( + name: 'StudentB', + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student B", + email: "studentb@example.com", + ) + end + + #instructor is created as an associate of the assignment + let!(:instructor) do + User.create!( + name: "Instructor", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor Name", + email: "instructor@example.com" + ) + end + let!(:assignment1) do + Assignment.create!( + name: 'Test Assignment', + instructor_id: instructor.id + ) + end + let!(:team) do + Team.create!( + name: 'Team A', + assignment: assignment1, + users: [studenta], #only student a is on the team to test if student b can access + parent_id: 1 + ) + end + let(:submission_record) do + SubmissionRecord.create!( + id: 1, + team_id: team.id, + assignment_id: assignment1.id, + ) + end + + #SET UP WEB TOKENS SO THAT WE CAN TEST HTTP RESPONSE REQUESTS + let(:token) { JsonWebToken.encode({id: studenta.id}) } + let(:Authorization) { "Bearer #{token}" } + + #INDEX TESTING + # GET /api/v1/student_task (Get student tasks) + + describe 'GET /submission_records/:id' do + context 'when the student is part of the team' do + it 'allows access and returns a 200 status' do + get "/submission_records/#{submission_record.id}", headers: valid_headers + expect(response).to have_http_status(:ok) + end + end + + context 'when the student is NOT part of the team' do + it 'denies access and returns a 403 status' do + get "/submission_records/#{submission_record.id}", headers: unauthorized_headers + expect(response).to have_http_status(:forbidden) + end + end + + context 'when an invalid token is provided' do + it 'returns a 401 status' do + get "/submission_records/#{submission_record.id}", headers: invalid_headers + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /submission_records' do + context 'when the student belongs to a team' do + it 'retrieves submission records and returns a 200 status' do + get "/submission_records", params: { team_id: team.id }, headers: valid_headers + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body).length).to be > 0 + end + end + + context 'when the student does NOT belong to a team' do + it 'denies access and returns a 403 status' do + get "/submission_records", params: { team_id: team.id }, headers: unauthorized_headers + expect(response).to have_http_status(:forbidden) + end + end + end + + describe 'Edge Cases' do + context 'when requesting a non-existent submission record' do + it 'returns a 404 status' do + get "/submission_records/99999", headers: valid_headers + expect(response).to have_http_status(:not_found) + end + end + end +end \ No newline at end of file