Commit 048144cc authored by Florent Chehab's avatar Florent Chehab

feat(sharedUnivFeedback): back & front | Tweaks

Shared Univ Feedback:
* back done
* Front done
* auto created on univ creation

Tweaks:
* added check_obj_permissions_for_edit to essential serializer to be able to deeper check permissions for user_can_edit
* Fixed ExchangePermission to handle null student
* Visual simplification of previous exchanges with no data
parent 80323181
Pipeline #42721 passed with stages
in 3 minutes and 54 seconds
......@@ -20,6 +20,7 @@ from backend_app.models.offer import Offer
from backend_app.models.partner import Partner
from backend_app.models.pendingModeration import PendingModeration
from backend_app.models.recommendationList import RecommendationList
from backend_app.models.sharedUnivFeedback import SharedUnivFeedback
from backend_app.models.taggedItems import UniversityTaggedItem, CountryTaggedItem
from backend_app.models.university import University
from backend_app.models.universityDri import UniversityDri
......@@ -49,6 +50,7 @@ ALL_MODELS = [
RecommendationList,
Partner,
University,
SharedUnivFeedback,
UniversityDri,
UniversityInfo,
UniversityScholarship,
......
# Generated by Django 2.1.7 on 2019-06-30 09:19
import backend_app.models.abstract.essentialModule
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("backend_app", "0005_auto_20190630_0937"),
]
operations = [
migrations.CreateModel(
name="SharedUnivFeedback",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("updated_on", models.DateTimeField(null=True)),
("moderated_on", models.DateTimeField(null=True)),
(
"obj_moderation_level",
models.SmallIntegerField(
default=0,
validators=[
django.core.validators.MinValueValidator(0),
backend_app.models.abstract.essentialModule.validate_obj_model_lv,
],
),
),
("has_pending_moderation", models.BooleanField(default=False)),
("nb_versions", models.PositiveIntegerField(default=0)),
("comment", models.CharField(blank=True, default="", max_length=5000)),
(
"moderated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"university",
models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="backend_app.University",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"abstract": False},
),
migrations.AlterField(
model_name="exchange",
name="student",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="exchanges",
to=settings.AUTH_USER_MODEL,
),
),
]
......@@ -123,6 +123,8 @@ class EssentialModuleSerializer(BaseModelSerializer):
ValidationError -- If you are trying to moderate something you don't have rights to
"""
check_obj_permissions_for_edit = False
######
# Basic fields serializers
updated_by = serializers.SerializerMethodField()
......@@ -161,8 +163,20 @@ class EssentialModuleSerializer(BaseModelSerializer):
# Anyway, those Viewsets should be readonly, so we can return false.
user_can_edit = False
if user_can_edit and self.check_obj_permissions_for_edit:
try:
fake_edit_request = FakeRequest(self.get_user_from_request(), "PUT")
for permission_class in self.context["permission_classes"]:
if not permission_class.has_object_permission(
fake_edit_request, None, obj
):
user_can_edit = False
break
except KeyError:
pass
obj_info["user_can_edit"] = user_can_edit
obj_info["user_can_moderate"] = not is_moderation_required(
obj_info["user_can_moderate"] = user_can_edit and not is_moderation_required(
self.Meta.model, obj, self.get_user_from_request()
)
return obj_info
......@@ -377,12 +391,16 @@ class EssentialModuleViewSet(BaseModelViewSet):
# Beware, that this might provide inconsistent data to the frontend
# especially if permission_classes impact at the object level such as
# IsOwner.
#
# Set check_obj_permissions_for_edit=True in your serializer
# if you want a better check at the object level
if not permission_class.has_permission(fake_edit_request, None):
user_can_edit = False
break
default_context = super().get_serializer_context()
default_context["user_can_edit"] = user_can_edit
default_context["permission_classes"] = self.get_permissions()
return default_context
def get_queryset(self):
......
......@@ -43,7 +43,9 @@ class Exchange(BaseModel):
partner = models.ForeignKey(Partner, on_delete=models.PROTECT, null=True)
university = models.ForeignKey(University, on_delete=models.PROTECT, null=True)
# (managned by signals on course save)
student = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
student = models.ForeignKey(
User, on_delete=models.CASCADE, null=True, related_name="exchanges"
)
# Field to tell that for some reason there is no corresponding exchange in the UTC DB
unlinked = models.BooleanField(default=False, null=False)
......
......@@ -44,6 +44,8 @@ class ExchangeFeedback(EssentialModule):
class ExchangeFeedbackSerializer(EssentialModuleSerializer):
check_obj_permissions_for_edit = True
exchange = ExchangeSerializer(read_only=True)
def update(self, instance, validated_data):
......@@ -70,6 +72,9 @@ class ExchangePermission(BasePermission):
"""
def has_object_permission(self, request, view, obj):
exchange = obj.exchange
if exchange.student is None:
return False
return request.user.pk == obj.exchange.student.pk
......
from django.db import models
from rest_framework.permissions import BasePermission
from backend_app.models.abstract.versionedEssentialModule import (
VersionedEssentialModule,
VersionedEssentialModuleSerializer,
VersionedEssentialModuleViewSet,
)
from backend_app.models.exchange import Exchange
from backend_app.models.university import University
from backend_app.permissions.app_permissions import ReadOnly, IsStaff, NoDelete, NoPost
from backend_app.permissions.moderation import ModerationLevels
class SharedUnivFeedback(VersionedEssentialModule):
moderation_level = ModerationLevels.DEPENDING_ON_SITE_SETTINGS
university = models.OneToOneField(University, on_delete=models.PROTECT, null=True)
comment = models.CharField(default="", blank=True, max_length=5000)
class SharedUnivFeedbackSerializer(VersionedEssentialModuleSerializer):
check_obj_permissions_for_edit = True
class Meta:
model = SharedUnivFeedback
fields = VersionedEssentialModuleSerializer.Meta.fields + (
"university",
"comment",
)
read_only_fields = ("university",)
class SharedUnivPermission(BasePermission):
"""
Permission that checks that the requester went to the university.
"""
def has_object_permission(self, request, view, obj: SharedUnivFeedback):
return Exchange.objects.filter(
student=request.user, university=obj.university
).exists()
class SharedUnivFeedbackViewSet(VersionedEssentialModuleViewSet):
permission_classes = (
NoDelete & NoPost & (ReadOnly | IsStaff | SharedUnivPermission),
)
queryset = SharedUnivFeedback.objects.all()
serializer_class = SharedUnivFeedbackSerializer
end_point_route = "sharedUnivFeedbacks"
filterset_fields = ("university",)
required_filterset_fields = ("university",)
......@@ -5,6 +5,7 @@ from backend_app.models.course import Course
from backend_app.models.courseFeedback import CourseFeedback
from backend_app.models.exchange import Exchange
from backend_app.models.exchangeFeedback import ExchangeFeedback
from backend_app.models.sharedUnivFeedback import SharedUnivFeedback
from backend_app.models.taggedItems import (
UNIVERSITY_TAG_CHOICES,
UniversityTaggedItem,
......@@ -19,7 +20,7 @@ from backend_app.utils import get_module_defaults_for_bot, revision_bot
from base_app.models import User
def create_univ_modules(sender, instance, created, **kwargs):
def create_univ_modules(sender, instance: University, created, **kwargs):
if created:
defaults = get_module_defaults_for_bot()
with revision_bot():
......@@ -28,6 +29,10 @@ def create_univ_modules(sender, instance, created, **kwargs):
UniversitySemestersDates.objects.get_or_create(
university=instance, defaults=defaults
)
with revision_bot():
SharedUnivFeedback.objects.get_or_create(
university=instance, defaults=defaults
)
def create_user_modules(sender, instance, created, **kwargs):
......
......@@ -32,6 +32,7 @@ from backend_app.models.recommendationList import (
RecommendationListViewSet,
RecommendationList,
)
from backend_app.models.sharedUnivFeedback import SharedUnivFeedbackViewSet
from backend_app.models.taggedItems import (
CountryTaggedItemViewSet,
UniversityTaggedItemViewSet,
......@@ -132,6 +133,7 @@ ALL_API_VIEWSETS = [
UnivMajorMinorsViewSet,
RecommendationListViewSet,
UniversityViewSet,
SharedUnivFeedbackViewSet,
UniversityDriViewSet,
UniversityInfoViewSet,
UniversityScholarshipViewSet,
......
import React from "react";
import withStyles from "@material-ui/core/styles/withStyles";
import compose from "recompose/compose";
import {connect} from "react-redux";
import Editor from "../../editor/Editor";
import Form from "../../form/Form";
import editorStyle from "../../editor/editorStyle";
import getMapStateToPropsForEditor from "../../editor/getMapStateToPropsForEditor";
import getMapDispatchToPropsForEditor from "../../editor/getMapDispatchToPropsForEditor";
import {withSnackbar} from "notistack";
import Markdown from "../../common/markdown/Markdown";
const styles = theme => editorStyle(theme);
const infoSource = `
**NB :** pour toutes informations assez spécifiques vous avez l'onglet « Tips & Tricks »
sur la page de l'université. Dans ce dernier vous pourrez mettre les commentaires qui s'adressent
plutôt aux étudiantes et étudiants qui ont été sélectionnés pour partir en échange.
`;
class SharedUnivFeedbackForm extends Form {
render() {
return (
<>
<Markdown source={infoSource}/>
{this.renderMarkdownField(
{
label: "Commentaire sur l'université partagé par tous les étudiants s'y étant rendu",
maxLength: 5000,
fieldMapping: "comment",
})
}
</>
);
}
}
class SharedUnivFeedbackEditor extends Editor {
extraFieldMappings = ["university"];
renderForm() {
return (
<SharedUnivFeedbackForm
modelData={this.props.rawModelData}
ref={this.formRef}
/>
);
}
}
export default compose(
withSnackbar,
withStyles(styles, {withTheme: true}),
connect(
getMapStateToPropsForEditor("sharedUnivFeedbacks"),
getMapDispatchToPropsForEditor("sharedUnivFeedbacks")
)
)(SharedUnivFeedbackEditor);
import React from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
import compose from "recompose/compose";
import {connect} from "react-redux";
import ModuleWrapper from "./common/ModuleWrapper";
import Module from "./common/Module";
import SharedUnivFeedbackEditor from "../editors/SharedUnivFeedbackEditor";
import getActions from "../../../redux/api/getActions";
import {withUnivInfo} from "../common/withUnivInfo";
import {RequestParams} from "../../../redux/api/RequestParams";
import TruncatedMarkdown from "../../common/markdown/TruncatedMarkdown";
const styles = theme => ({
root: {
width: "100%",
overflowX: "auto",
},
tableCell: {
padding: "2px",
},
content: {
display: "flex",
alignItems: "center",
},
icon: {
paddingRight: theme.spacing(1)
}
});
// eslint-disable-next-line no-unused-vars
function renderCore(rawModelData, classes) {
let {comment} = rawModelData;
return <TruncatedMarkdown truncateFromLength={1000} source={comment}/>;
}
class SharedUnivFeedback extends Module {
apiParams = {
univSharedFeedback: ({props}) => RequestParams.Builder.withId(props.univId).build(),
};
customRender() {
const univSharedFeedback = this.getLatestReadData("univSharedFeedback");
const {classes} = this.props;
return (
<ModuleWrapper
buildTitle={() => "Commentaire partagé par celles et ceux qui sont partis"}
rawModelData={univSharedFeedback}
editor={SharedUnivFeedbackEditor}
renderCore={renderCore}
coreClasses={classes}
/>
);
}
}
SharedUnivFeedback.propTypes = {
classes: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
univId: PropTypes.string.isRequired
};
const mapStateToProps = (state) => {
return {
univSharedFeedback: state.api.sharedUnivFeedbacksOne,
};
};
const mapDispatchToProps = (dispatch) => {
return {
api: {
univSharedFeedback: (params) => dispatch(getActions("sharedUnivFeedbacks").readOne(params)),
},
invalidateData: () => dispatch(getActions("sharedUnivFeedbacks").invalidateOne())
};
};
export default compose(
withUnivInfo(),
withStyles(styles, {withTheme: true}),
connect(mapStateToProps, mapDispatchToProps)
)(SharedUnivFeedback);
......@@ -12,7 +12,7 @@ export default function renderUpdateInfo() {
if (!userCanEdit) {
return (
<Typography variant='caption'>Ils s'agit de données automatiquement récupérées et non modifiables.</Typography>
<Typography variant='caption'>Vous n'avez pas le droit de modifier ce module.</Typography>
);
} else {
const info = <LinkToUser userId={updaterId} pseudo={updaterPseudo}/>;
......
......@@ -156,12 +156,16 @@ function PreviousExchange(props) {
function renderCore() {
return (
<>
<GeneralFeedbackCore academicalLevel={rawModelData.academical_level_appreciation}
foreignStudentWelcome={rawModelData.foreign_student_welcome}
culturalInterest={rawModelData.cultural_interest}
generalComment={rawModelData.general_comment}
untouched={rawModelData.untouched}/>
{
rawModelData.untouched ?
<></>
:
<GeneralFeedbackCore academicalLevel={rawModelData.academical_level_appreciation}
foreignStudentWelcome={rawModelData.foreign_student_welcome}
culturalInterest={rawModelData.cultural_interest}
generalComment={rawModelData.general_comment}
untouched={rawModelData.untouched}/>
}
{
courses.length === 0 ?
<Typography variant={"caption"}>Aucun cours n'est rataché à cet échange.</Typography>
......@@ -191,7 +195,11 @@ function PreviousExchange(props) {
<div>
<Chip label={cleanSemester} className={classes.chip} color="primary"/>
<Chip label={`${majorSemester}${minor ? " — " + minor : ""}`} className={classes.chip} color="secondary"/>
<Typography display={"inline"}><LinkToUser userId={updaterId} pseudo={updaterUseName}/></Typography>
{updaterId ?
<Typography display={"inline"}>
<LinkToUser userId={updaterId} pseudo={updaterUseName}/></Typography>
: <></>
}
</div>
<div> {/* Needed to fire events for the tooltip when below is disabled!! */}
......
......@@ -6,6 +6,7 @@ import UniversityOffers from "../modules/UniversityOffers";
// import UniversityDri from "../modules/UniversityDri";
// import CountryDri from "../modules/CountryDri";
import {makeStyles} from "@material-ui/styles";
import SharedUnivFeedback from "../modules/SharedUnivFeedback";
const useStyles = makeStyles(theme => ({
......@@ -59,7 +60,7 @@ function GeneralInfoTab() {
<div className={classes.wideRoot}>
<div className={classes.wideLeftCol}>
{
[UniversityGeneral,].map((Comp, key) =>
[UniversityGeneral, SharedUnivFeedback].map((Comp, key) =>
<div key={key} className={classes.wideColItem}>
<Comp/>
</div>
......@@ -79,7 +80,7 @@ function GeneralInfoTab() {
<div className={classes.smallRoot}>
<div className={classes.spacer}/>
{
[UniversityGeneral, UniversityOffers, UniversitySemestersDates].map((Comp, key) =>
[UniversityGeneral, UniversityOffers, SharedUnivFeedback, UniversitySemestersDates].map((Comp, key) =>
<div key={key} className={classes.smallColItem}>
<Comp/>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment