/* * #%L * Course Signup Webapp * %% * Copyright (C) 2010 - 2013 University of Oxford * %% * Licensed under the Educational Community License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://opensource.org/licenses/ecl2 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * #L% */ package uk.ac.ox.oucs.vle.resources; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.codehaus.jackson.JsonGenerationException; import org.codehaus.jackson.map.JsonMappingException; import org.codehaus.jackson.map.ObjectMapper; import org.codehaus.jackson.map.type.TypeFactory; import org.glassfish.jersey.server.mvc.Viewable; import org.sakaiproject.component.api.ServerConfigurationService; import uk.ac.ox.oucs.vle.*; import uk.ac.ox.oucs.vle.CourseSignupService.Status; import javax.inject.Inject; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.ResponseBuilder; import javax.ws.rs.core.StreamingOutput; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.text.SimpleDateFormat; import java.util.*; @Path("signup{cobomo:(/cobomo)?}") public class SignupResource { private static final Log log = LogFactory.getLog(SignupResource.class); @Inject private CourseSignupService courseService; @Inject private StatusProgression statusProgression; @Inject private ServerConfigurationService serverConfigurationService; @Inject private SakaiProxy proxy; @Inject private ObjectMapper objectMapper; @Path("/my") @GET @Produces(MediaType.APPLICATION_JSON) public StreamingOutput getMySignups() { checkAuthenticated(); final List<CourseSignup> signups = courseService.getMySignups(null); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { objectMapper.typedWriter(TypeFactory.collectionType(List.class, CourseSignup.class)).writeValue(output, signups); } }; } @Path("/my/course/{id}") @GET @Produces(MediaType.APPLICATION_JSON) public StreamingOutput getMyCourseSignups(@PathParam("id") String courseId) { checkAuthenticated(); final List<CourseSignup> signups = courseService.getMySignups(null); final List<CourseSignup> courseSignups = new ArrayList<CourseSignup>(); for(CourseSignup signup: signups) { if (courseId.equals(signup.getGroup().getCourseId())) { courseSignups.add(signup); } } return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { objectMapper.typedWriter(TypeFactory.collectionType(List.class, CourseSignup.class)).writeValue(output, courseSignups); } }; } /** * Make a new signup for the current user. * * @param courseId * the courseId of the signup * @param components * the components to sign up to * @param email * the email of the supervisor * @param message * the reason for the signup * @param specialReq * any special requirements * @return CourseSignup * the coursesignup object created */ @Path("/my/new") @POST @Produces(MediaType.APPLICATION_JSON) public Response signup(@FormParam("courseId") String courseId, @FormParam("components")Set<String> components, @FormParam("email")String email, @FormParam("message")String message, @FormParam("specialReq")String specialReq) { checkAuthenticated(); try { CourseSignup entity = courseService.signup(courseId, components, email, message, specialReq); ResponseBuilder builder = Response.status(Response.Status.CREATED); builder.entity(entity); return builder.build(); } catch (IllegalStateException e) { throw new WebAppBadRequestException(new FailureMessage(e.getMessage())); } catch (IllegalArgumentException e) { throw new WebAppBadRequestException(new FailureMessage(e.getMessage())); } } /** * Create a signup specifying the user. * * @param userId The ID of the user to be signed up. If <code>null</code> the we use the email address to lookup user. * If the string "newUser" is supplied we attempt to create a new user anyway (deprecated). * @param userName The name of the user if we are creating a new user. * @param userEmail The email address of the user. * @param courseId The course ID to sign up to. Cannot be <code>null</code>. * @param components The components IDs to sign up to. Cannot be <code>null</code>. * @param supervisorId The ID of the supervisor to link the signups to. Can be <code>null</code>. * @return The created CourseSignup. * @see CourseSignupService#signup(String, String, String, String, java.util.Set, String) */ @Path("/new") @POST @Produces(MediaType.APPLICATION_JSON) public Response signup( @FormParam("userId")String userId, @FormParam("userName")String userName, @FormParam("userEmail")String userEmail, @FormParam("courseId") String courseId, @FormParam("components")Set<String> components, @FormParam("supervisorId")String supervisorId) { checkAuthenticated(); // Support old idea of a special ID for new users. // When the frontend is refactored this can be removed. if ("newUser".equals(userId)) { userId = null; } CourseSignup signup = courseService.signup(userId, userName, userEmail, courseId, components, supervisorId); return Response.status(Response.Status.CREATED).entity(signup).build(); } @Path("/supervisor") @POST public Response signup(@FormParam("signupId")String signupId, @FormParam("supervisorId")String supervisorId) { checkAuthenticated(); courseService.setSupervisor(signupId, supervisorId); return Response.ok().build(); } @Path("/{id}") @GET @Produces("application/json") public Response getSignup(@PathParam("id") final String signupId) throws JsonGenerationException, JsonMappingException, IOException { checkAuthenticated(); CourseSignup signup = courseService.getCourseSignup(signupId); if (signup == null) { return Response.status(javax.ws.rs.core.Response.Status.NOT_FOUND).build(); } return Response.ok(objectMapper.writeValueAsString(signup)).build(); } @Path("/{id}") @POST // PUT Doesn't seem to make it through the portal :-( public void updateSignup(@PathParam("id") final String signupId, @FormParam("status") final Status status){ checkAuthenticated(); courseService.setSignupStatus(signupId, status); } @Path("{id}/accept") @POST public Response accept(@PathParam("id") final String signupId) { checkAuthenticated(); courseService.accept(signupId); return Response.ok().build(); } @Path("{id}/reject") @POST public Response reject(@PathParam("id") final String signupId) { checkAuthenticated(); courseService.reject(signupId); return Response.ok().build(); } @Path("{id}/withdraw") @POST public Response withdraw(@PathParam("id") final String signupId) { checkAuthenticated(); courseService.withdraw(signupId); return Response.ok().build(); } @Path("{id}/waiting") @POST public Response waiting(@PathParam("id") final String signupId) { checkAuthenticated(); courseService.waiting(signupId); return Response.ok().build(); } @Path("{id}/approve") @POST public Response approve(@PathParam("id") final String signupId) { checkAuthenticated(); courseService.approve(signupId); return Response.ok().build(); } @Path("{id}/confirm") @POST public Response confirm(@PathParam("id") final String signupId) { checkAuthenticated(); courseService.confirm(signupId); return Response.ok().build(); } @Path("{id}/split") @POST public Response split(@PathParam("id") final String signupId, @FormParam("componentPresentationId") final Set<String> componentIds) { checkAuthenticated(); try { String newSignupId = courseService.split(signupId, componentIds); return Response.status(Response.Status.CREATED).entity(newSignupId).build(); } catch(IllegalArgumentException iae) { throw new CourseSignupException(iae.getMessage(), iae); } } @Path("/course/{id}") @GET @Produces(MediaType.APPLICATION_JSON) public StreamingOutput getCourseSignups(@PathParam("id") final String courseId, @QueryParam("status") final Status status) { checkAuthenticated(); // All the pending return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { Set<Status> statuses = null; if (null != status) { statuses = Collections.singleton(status); } List<CourseSignup> signups = courseService.getCourseSignups(courseId, statuses); objectMapper.typedWriter(TypeFactory.collectionType(List.class, CourseSignup.class)).writeValue(output, signups); } }; } @Path("/count/course/signups/{id}") @GET @Produces(MediaType.APPLICATION_JSON) public Response getCountCourseSignup(@PathParam("id") final String courseId, @QueryParam("status") final Status status) throws JsonGenerationException, JsonMappingException, IOException { checkAuthenticated(); // All the pending Set<Status> statuses = null; if (null != status) { statuses = Collections.singleton(status); } Integer signups = courseService.getCountCourseSignups(courseId, statuses); return Response.ok(objectMapper.writeValueAsString(signups)).build(); } @Path("/component/{id}") @GET @Produces(MediaType.APPLICATION_JSON) public StreamingOutput getComponentSignups(@PathParam("id") final String componentId, @QueryParam("status") final Status status) { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { Set<Status> statuses = null; if (null != status) { statuses = Collections.singleton(status); } List<CourseSignup> signups = courseService.getComponentSignups(componentId, statuses); objectMapper.typedWriter(TypeFactory.collectionType(List.class, CourseSignup.class)).writeValue(output, signups); } }; } @Path("/component/{id}.csv") @GET @Produces("text/comma-separated-values") public StreamingOutput getComponentSignupsCSV(@PathParam("id") final String componentId, @Context final HttpServletResponse response) { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { CourseComponent component = courseService.getCourseComponent(componentId); List<CourseSignup> signups = courseService.getComponentSignups(componentId, null); response.addHeader("Content-disposition", "attachment; filename="+getFileName(component)+".csv"); // Force a download Writer writer = new OutputStreamWriter(output); CSVWriter csvWriter = new CSVWriter(writer); csvWriter.writeln(new String[]{ component.getTitle(), startsText(component)}); csvWriter.writeln(new String[]{ "Surname", "Forname", "Email", "SES Status", "Year of Study", "Degree Programme", "Affiliation"}); for(CourseSignup signup : signups) { Person user = signup.getUser(); if (null == user) { continue; } csvWriter.writeln(new String[]{ user.getLastName(), user.getFirstName(), user.getEmail(), signup.getStatus().toString(), user.getYearOfStudy(), user.getDegreeProgram(), buildString(user.getUnits())}); } writer.flush(); } }; } @Path("/component/{id}.pdf") @GET @Produces("application/pdf") public StreamingOutput getComponentSignupsPDF(@PathParam("id") final String componentId, @Context final HttpServletResponse response) { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { CourseComponent courseComponent = courseService.getCourseComponent(componentId); Collection<CourseGroup> courseGroups = courseService.getCourseGroupsByComponent(componentId); List<CourseSignup> signups = courseService.getComponentSignups( componentId, Collections.singleton(Status.CONFIRMED)); response.addHeader("Content-disposition", "attachment; filename="+componentId+".pdf"); // Force a download PDFWriter pdfWriter = new PDFWriter(output); pdfWriter.writeHead(courseGroups, courseComponent); pdfWriter.writeTableHead(); if (!signups.isEmpty()) { List<Person> persons = new ArrayList<Person>(); for (CourseSignup signup : signups) { if (null != signup.getUser()) { persons.add(signup.getUser()); } } Collections.sort(persons, new Comparator<Person>() { public int compare(Person p1,Person p2) { return p1.getLastName().compareTo(p2.getLastName()); } }); pdfWriter.writeTableBody(persons); } pdfWriter.writeTableFoot(); pdfWriter.close(); } }; } /** * Exports all the signups for a year. * @param status If specified only export signups with this status. * @param year The year to export components from. * @return A streaming out that writes out XML. * @see #exportComponent(String, uk.ac.ox.oucs.vle.CourseSignupService.Status, int) */ @Path("/component/{year}.xml") @GET @Produces(MediaType.TEXT_XML) public StreamingOutput exportYear(@QueryParam("status") final Status status, @PathParam("year") final int year) { return exportComponent("all", status, year); } /** * Export signups for a component or if "all" all components in that year. * We support the "all" parameter so that existing URLs don't break. * @param componentId The component ID to export the signups for. * @param status If specified only export signups with this status. * @param year The year to export components from. * @return A streaming out that writes out XML. */ @Path("/component/{year}/{id}.xml") @GET @Produces(MediaType.TEXT_XML) public StreamingOutput exportComponent(@PathParam("id") final String componentId, @QueryParam("status") final Status status, @PathParam("year") final int year) { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { Set<Status> statuses = null; if (null != status) { statuses = Collections.singleton(status); } List<CourseComponentExport> components = courseService.exportComponentSignups(componentId, statuses, year); AttendanceWriter attendance = new AttendanceWriter(output); for (CourseComponentExport courseComponent : components) { List<CourseSignupExport> signups = courseComponent.getSignups(); Collections.sort(signups, new Comparator<CourseSignupExport>() { public int compare(CourseSignupExport s1, CourseSignupExport s2) { Person p1 = s1.getSignup().getUser(); Person p2 = s2.getSignup().getUser(); int ret = s1.getGroup().getCourseId().compareTo(s2.getGroup().getCourseId()); // this line is giving a NullPointerException //return ret == 0 ? p1.getLastName().compareTo(p2.getLastName()) : ret; if (ret != 0) { return ret; } if (p1 == null) { return (p2 == null) ? 0 : -1; } if (p2 == null) { return 1; } if (p1.getLastName() == null) { return (p2.getLastName() == null) ? 0 : -1; } if (p2.getLastName() == null) { return 1; } return p1.getLastName().compareTo(p2.getLastName()); } }); attendance.writeTeachingInstance(courseComponent); } attendance.close(); } }; } @Path("/attendance") @GET @Produces(MediaType.TEXT_XML) public StreamingOutput sync() { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { AttendanceWriter attendance = new AttendanceWriter(output); List<CourseComponentExport> signups = courseService.exportComponentSignups(null, Collections.singleton(Status.CONFIRMED), null); for (CourseComponentExport courseComponent : signups ) { attendance.writeTeachingInstance(courseComponent); } attendance.close(); } }; } @Path("/pending") @GET @Produces(MediaType.APPLICATION_JSON) public StreamingOutput getPendingSignups() { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { List<CourseSignup> signups = courseService.getPendings(); objectMapper.typedWriter(TypeFactory.collectionType(List.class, CourseSignup.class)).writeValue(output, signups); } }; } @Path("/approve") @GET @Produces(MediaType.APPLICATION_JSON) public StreamingOutput getApproveSignups() { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { List<CourseSignup> signups = courseService.getApprovals(); objectMapper.typedWriter(TypeFactory.collectionType(List.class, CourseSignup.class)).writeValue(output, signups); } }; } @Path("/previous") @GET @Produces(MediaType.APPLICATION_JSON) public StreamingOutput getPreviousSignups(@QueryParam("userid") final String userId, @QueryParam("componentid") final String componentId, @QueryParam("groupid") final String groupId) { checkAuthenticated(); return new StreamingOutput() { public void write(OutputStream output) throws IOException, WebApplicationException { List<String> componentIds = Arrays.asList(componentId.split(",")); Set<CourseSignup> signups = new HashSet<CourseSignup>(); for (CourseSignup signup : courseService.getUserComponentSignups(userId, null)) { if (signup.getGroup().getCourseId().equals(groupId)) { for (CourseComponent component : signup.getComponents()) { if (!componentIds.contains(component.getPresentationId())) { signups.add(signup); } } } } objectMapper.typedWriter(TypeFactory.collectionType(Set.class, CourseSignup.class)).writeValue(output, signups); } }; } @Path("/advance/{id}") @GET @Produces("text/html") public Response advanceGet(@PathParam("id") final String encoded) { String[] params = courseService.getCourseSignupFromEncrypted(encoded); if (log.isDebugEnabled()) { for (int i = 0; i < params.length; i++) { log.debug("decoded parameter [" + params[i] + "]"); } } String signupId = params[0]; // This is the status that is being advanced to. String emailStatus = params[1]; Status status = toStatus(emailStatus); CourseSignup signup = courseService.getCourseSignupAnyway(signupId); Map<String, Object> model = new HashMap<>(); model.put("signup", signup); model.put("encoded", encoded); // Check that the status we're trying to advance to is valid if (!statusProgression.next(signup.getStatus()).contains(status)) { model.put("errors", Collections.singletonList("The signup has already been dealt with.")); } else { // We only put the status in if we're happy for it to be changed. model.put("status", emailStatus); } addStandardAttributes(model); return Response.ok(new Viewable("/static/advance", model)).build(); } @Path("/advance/{id}") @POST @Produces("text/html") public Response advancePost(@PathParam("id") final String encoded, @FormParam("formStatus") final String formStatus) { if (null == encoded) { return Response.noContent().build(); } String[] params = courseService.getCourseSignupFromEncrypted(encoded); String signupId = params[0]; Status status = toStatus(params[1]); String placementId = params[2]; CourseSignup signup = courseService.getCourseSignupAnyway(signupId); if (null == signup) { return Response.noContent().build(); } Map<String, Object> model = new HashMap<String, Object>(); model.put("signup", signup); if (!statusProgression.next(signup.getStatus()).contains(status)) { model.put("errors", Collections.singletonList("The signup has already been dealt with.")); } else { try { switch (formStatus.toLowerCase()) { case "accept": courseService.accept(signupId, true, placementId); break; case "approve": courseService.approve(signupId, true, placementId); break; case "confirm": courseService.confirm(signupId, true, placementId); break; case "reject": courseService.reject(signupId, true, placementId); break; default: throw new IllegalStateException("No mapping for action of: "+ formStatus); } } catch (IllegalStateException ise) { model.put("errors", Collections.singletonList(ise.getMessage())); } } addStandardAttributes(model); return Response.ok(new Viewable("/static/ok", model)).build(); } /** * The statuses that go out in emails are action rather that actual statuses, this * method converts the email status into an actual status. * @param emailStatus The status to convert. * @thows IllegalArgumentException */ public Status toStatus(String emailStatus) { switch (emailStatus) { case "accept": return Status.ACCEPTED; case "approve": return Status.APPROVED; case "confirm": return Status.CONFIRMED; case "reject": return Status.REJECTED; default: throw new IllegalArgumentException("Not a valid email status: "+ emailStatus); } } /** * This just adds the standard skin values that are needed when rendering a page. * @param model The model to add the values to. */ public void addStandardAttributes(Map<String, Object> model) { model.put("skinRepo", serverConfigurationService.getString("skin.repo", "/library/skin")); model.put("skinDefault", serverConfigurationService.getString("skin.default", "default")); model.put("skinPrefix", serverConfigurationService.getString("portal.neoprefix", "")); } private String buildString(Collection<String> collection) { StringBuilder sb = new StringBuilder(); if (!collection.isEmpty()) { for(String s: collection) { sb.append(s).append('/'); } } return sb.toString(); } private String startsText(CourseComponent component) { if (null != component.getStartsText() && !component.getStartsText().isEmpty()) { return component.getStartsText(); } if (null != component.getStarts()) { return new SimpleDateFormat("EEE d MMM yyyy").format(component.getStarts()); } return ""; } private String getFileName(CourseComponent component) { StringBuilder sb = new StringBuilder(); sb.append(component.getPresentationId().replaceAll(" ", "_")); if (null != component.getWhen()) { sb.append("_"); sb.append(component.getWhen().replaceAll(" ", "_")); } return sb.toString(); } /** * This just checks that the request is authenticated and if no throws an exception. * @throws WebAppForbiddenException */ private void checkAuthenticated() { if(proxy.isAnonymousUser()) { throw new WebAppForbiddenException(); } } }