Commit abbba24b authored by Florent Chehab's avatar Florent Chehab

Feature(cover photo): setup ground

* Setup ground for cover photo. Merged early for beta.
* Models updated to use files
* Fixed file/picture serializers
* CoverGallery component updated
* Added fileField  / pageFiles / Picture / Picture editor & tweaked crud actions to be able to post data (would need further testing)
* Fixed general info tab elements size

* TODOs are identified by WARNING

WIP #50
parent 638abfbf
Pipeline #42726 passed with stages
in 3 minutes and 54 seconds
......@@ -39,7 +39,7 @@ class LoadUniversities(LoadGeneric):
"name": row["university"],
"acronym": row["acronym"],
"website": row["website"],
"logo": row["logo"],
# "logo": row["logo"], # WARNING FIX BETA not ok
},
)[0]
self.add_info_and_save(univ, self.admin)
......
# Generated by Django 2.1.7 on 2019-06-30 16:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("backend_app", "0007_auto_20190630_1753")]
operations = [
migrations.AddField(
model_name="universityinfo",
name="cover_photos",
field=models.ManyToManyField(to="backend_app.Picture"),
),
migrations.AlterField(
model_name="university",
name="logo",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="backend_app.Picture",
),
),
]
from django.db import models
from rest_framework import serializers
from rest_framework.response import Response
from backend_app.models.abstract.base import (
BaseModel,
......@@ -29,7 +28,8 @@ class AbstractFile(BaseModel):
class File(AbstractFile):
pass
class Meta:
pass
class Picture(AbstractFile):
......@@ -45,7 +45,7 @@ class Picture(AbstractFile):
# Serializers
#########
# WARNING FIX BETA delete also file on delete
class FileSerializer(BaseModelSerializer):
owner = serializers.StringRelatedField(read_only=True)
......@@ -98,22 +98,20 @@ class BaseFileViewSet(BaseModelViewSet):
else:
return self._serializer_not_read_only
def list(self, request, *args, **kwargs):
# Prevent the querying of all objects.
return Response(list())
permission_classes = (NoDelete | IsStaff, IsOwner | IsStaff | ReadOnly)
permission_classes = (NoDelete | IsStaff | IsOwner, IsOwner | IsStaff | ReadOnly)
class FileViewSet(BaseModelViewSet):
_serializer_not_read_only = PictureSerializer
_serializer_read_only = PictureSerializer
_serializer_not_read_only = FileSerializer
_serializer_read_only = FileSerializerFileReadOnly
queryset = File.objects.all()
filterset_fields = ("owner",)
end_point_route = "files"
class PictureViewSet(BaseFileViewSet):
_serializer_not_read_only = PictureSerializer
_serializer_read_only = PictureSerializer
_serializer_read_only = PictureSerializerFileReadOnly
queryset = Picture.objects.all()
filterset_fields = ("owner",)
end_point_route = "pictures"
import logging
from django.conf import settings
from django.db import models
from backend_app.fields import JSONField
......@@ -9,7 +8,7 @@ from backend_app.models.abstract.essentialModule import (
EssentialModuleSerializer,
EssentialModuleViewSet,
)
from backend_app.validation.validators import PathExtensionValidator
from backend_app.models.file_picture import Picture
logger = logging.getLogger("django")
......@@ -21,11 +20,7 @@ class University(EssentialModule):
name = models.CharField(max_length=200)
acronym = models.CharField(max_length=20, default="", blank=True)
logo = models.URLField(
default="",
blank=True,
validators=[PathExtensionValidator(settings.ALLOWED_PHOTOS_EXTENSION)],
)
logo = models.ForeignKey(Picture, null=True, on_delete=models.PROTECT)
website = models.URLField(default="", blank=True, max_length=300)
# a bit of denormalization
......
......@@ -3,6 +3,7 @@ from django.db import models
from backend_app.models.abstract.module import Module, ModuleSerializer, ModuleViewSet
from backend_app.models.currency import Currency
from backend_app.models.file_picture import Picture
from backend_app.models.university import University
......@@ -18,18 +19,24 @@ class UniversityInfo(Module):
cost_exchange = models.DecimalField(
decimal_places=2, max_digits=20, validators=[MinValueValidator(0)], null=True
)
cost_double_degree = models.DecimalField(
decimal_places=2, max_digits=20, validators=[MinValueValidator(0)], null=True
)
costs_currency = models.ForeignKey(Currency, on_delete=models.PROTECT, null=True)
cover_photos = models.ManyToManyField(Picture)
class UniversityInfoSerializer(ModuleSerializer):
class Meta:
model = UniversityInfo
fields = "__all__"
fields = ModuleSerializer.Meta.fields + (
"university",
"cost_exchange",
"cost_double_degree",
"cost_currency",
"cover_photos",
)
class UniversityInfoViewSet(ModuleViewSet):
......
......@@ -15,9 +15,7 @@ from backend_app.models.exchange import (
update_denormalized_univ_major_minor,
) # noqa: E402
from backend_app.models.university import (
update_denormalized_univ_field,
) # noqa: E402
from backend_app.models.university import update_denormalized_univ_field # noqa: E402
from external_data.management.commands.utils import FixerData, UtcData # noqa: E402
from base_app.management.commands.clean_user_accounts import (
ClearUserAccounts,
......
......@@ -29,6 +29,7 @@ import PageMyExchanges from "../pages/PageMyExchanges";
import NotifierImportantInformation from "./NotifierImportantInformation";
import FooterImportantInformation from "./FooterImportantInformation";
import PageAboutUnlinkedPartners from "../pages/PageAboutUnlinkedPartners";
// import PageFiles from "../pages/PageFiles";
/**
* @class App
......@@ -53,6 +54,7 @@ class App extends CustomComponentForAPI {
<Route path={APP_ROUTES.myExchanges} component={PageMyExchanges}/>
<Route path={APP_ROUTES.editPreviousExchangeWithParams} component={PageEditPreviousExchanges}/>
<Route path={APP_ROUTES.userWithParams} component={PageUser}/>
{/*<Route path={APP_ROUTES.userFilesWithParams} component={PageFiles}/> WARNING BETA*/}
<Route path={APP_ROUTES.aboutProject} component={PageAboutProject}/>
<Route path={APP_ROUTES.aboutRgpd} component={PageRgpd}/>
<Route path={APP_ROUTES.aboutCgu} component={PageCgu}/>
......
......@@ -24,6 +24,7 @@ export const secondaryMenuItems = [
export const infoMenuItems = [
item("Mes informations", APP_ROUTES.userCurrent, null),
item("Mes échanges", APP_ROUTES.myExchanges, null),
// item("Mes fichiers", APP_ROUTES.userFilesCurrent, null), // WARNING BETA
item("Le projet REX-DRI", APP_ROUTES.aboutProject, null),
item("Conditions d'utilisations", APP_ROUTES.aboutCgu, null),
item("Information RGPD", APP_ROUTES.aboutRgpd, null),
......@@ -32,4 +33,4 @@ export const infoMenuItems = [
export const settingsMenuItems = [
item("Personnalisation du thème", APP_ROUTES.themeSettings, null),
item("Se déconnecter", APP_ROUTES.logout, null, true),
];
\ No newline at end of file
];
......@@ -96,10 +96,10 @@ Alert.defaultProps = {
disagreeText: "Annuler",
infoText: "J'ai compris",
multilineButtons: false,
// eslint-disable-next-line no-undef
handleClose: () => { Console.error("Missing function for closing alert."); },
// eslint-disable-next-line no-undef
handleResponse: () => { Console.error("Missing function for handling post close."); }
// eslint-disable-next-line no-console
handleClose: () => { console.error("Missing function for closing alert."); },
// eslint-disable-next-line no-console
handleResponse: () => { console.error("Missing function for handling post close."); }
};
......
import React, {Component} from "react";
import AwesomeSlider from "react-awesome-slider";
import styles from "./CoverGallery.scss";
import withStyles from "@material-ui/core/styles/withStyles";
import {compose} from "recompose";
import PropTypes from "prop-types";
import PhotoSizeSelectActualIcon from "@material-ui/icons/PhotoSizeSelectActual";
import shuffle from "lodash/shuffle";
import Typography from "@material-ui/core/Typography";
/**
* Component to display a cover image gallery with custom styling done in the ./CoverGallery.scss file
* If no image is provided then a replacement is added :)
*
* @class CoverGallery
* @extends {React.Component}
*/
class CoverGallery extends Component {
render() {
const {classes, picturesSrc} = this.props;
return (
<div style={{position: "relative", width: "100%"}}>
{
picturesSrc.length === 0 ?
<div className={classes.missingPictureDiv}>
<PhotoSizeSelectActualIcon className={classes.missingPictureIcon}/>
</div>
:
<AwesomeSlider cssModule={styles}>
{
shuffle(picturesSrc).map(src => <div key={src} data-src={src}/>)
}
<div data-src="https://www.stcc.ch/wp-content/uploads/2017/11/epfl.jpg"/>
</AwesomeSlider>
}
<div className={classes.editButton}>
<Typography variant={"caption"}>
Les photos de couverture ne sont pas encore disponibles.
</Typography>
{/*<Button variant={"contained"}*/}
{/* color="default"*/}
{/* onClick={() => this.props.onEditButtonClick()}>*/}
{/* <span className={classes.editButtonLabel}>Éditer les photos</span>*/}
{/* <CreateIcon className={classes.rightIcon}/>*/}
{/*</Button>*/}
</div>
</div>
);
}
}
CoverGallery.propTypes = {
classes: PropTypes.object.isRequired,
picturesSrc: PropTypes.arrayOf(PropTypes.string).isRequired,
onEditButtonClick: PropTypes.func.isRequired,
};
CoverGallery.defaultProps = {
picturesSrc: [],
// eslint-disable-next-line no-console
onEditButtonClick: () => console.log("No function provided to edit cover gallery"),
};
const componentStyles = theme => ({
missingPictureDiv: {
height: "27vh",
maxHeight: 150,
background:
`repeating-linear-gradient(
45deg,
${theme.palette.background.default},
${theme.palette.background.default} 10px,
${theme.palette.background.paper} 10px,
${theme.palette.background.paper} 20px
)`
},
missingPictureIcon: {
marginLeft: "auto",
marginRight: "auto",
width: "100%",
height: "100%"
},
editButton: {
position: "absolute",
zIndex: 1000,
right: theme.spacing.unit * 2,
bottom: theme.spacing.unit * 2
},
editButtonLabel: {
[theme.breakpoints.down("sm")]: {
display: "none"
}
},
rightIcon: {
[theme.breakpoints.up("md")]: {
marginLeft: theme.spacing.unit
}
}
});
export default compose(
withStyles(componentStyles, {withTheme: true})
)(CoverGallery);
@import "../../../../node_modules/react-awesome-slider/src/core/styles.scss";
@import "../../../../node_modules/react-awesome-slider/src/core/styles";
// Direct scss customization
.#{$root-element} {
......
import React from "react";
import Alert from "./Alert";
import PropTypes from "prop-types";
import {compose} from "recompose";
import getActions from "../../redux/api/getActions";
import {RequestParams} from "../../redux/api/RequestParams";
import {connect} from "react-redux";
class DeleteHandler extends React.Component {
handleDelete() {
const {route, id} = this.props;
this.props.delete(route, id, () => this.props.close());
}
render() {
return (
<Alert open={true}
info={false}
title="Confirmer la suppression de l'object."
description="Ếtes-vous sûr⋅e ?"
agreeText="Oui"
disagreeText="Non"
handleClose={() => this.props.close()}
handleResponse={(confirmed) => confirmed ? this.handleDelete() : this.props.close()}
/>
);
}
}
DeleteHandler.propTypes = {
route: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string.isRequired, PropTypes.number.isRequired]).isRequired,
close: PropTypes.func.isRequired,
delete: PropTypes.func.isRequired,
};
const mapDispatchToProps = (dispatch) => {
return {
delete: (route, id, onSuccess) => dispatch(getActions(route).delete(RequestParams.Builder.withId(id).withOnSuccessCallback(onSuccess).build())),
};
};
export default compose(
connect(() => ({}), mapDispatchToProps),
)(DeleteHandler);
\ No newline at end of file
import getActions from "../../redux/api/getActions";
import { openFullScreenDialog, closeFullScreenDialog } from "../../redux/actions/fullScreenDialog";
import {closeFullScreenDialog, openFullScreenDialog} from "../../redux/actions/fullScreenDialog";
import {RequestParams} from "../../redux/api/RequestParams";
/**
......
......@@ -97,7 +97,7 @@ class Field extends PureComponent {
* Function that should render the field itself
*
* MUST BE OVERRIDEN
*
* @returns "*"
* @virtual
*/
renderField() {
......
import React from "react";
import Field from "./Field";
import CustomError from "../../common/CustomError";
import Button from "@material-ui/core/Button";
import uuid from "uuid/v4";
import PropTypes from "prop-types";
/**
* Form field for a file
* WARNING BETA
* Doesn't support edite at this time
*
* @class FileField
* @extends {Field}
*/
class FileField extends Field {
state = {id: uuid(), fileName: undefined};
/**
* @override
* @returns
*/
getError() {
const {value} = this.state;
let messages = [];
if (typeof value === "undefined" || value === "") {
messages.push("Aucun fichier n'est sélectioné.");
}
return new CustomError(messages);
}
handleChangeValue(value) {
this.setState({value});
}
serializeFromField() {
const input = document.getElementById(this.state.id);
if (input) {
return input.files[0];
} else {
return "";
}
}
/**
* @override
* @returns
*/
renderField() {
let {value} = this.state;
if (typeof value !== "undefined") {
value = value.split(/(\\|\/)/g).pop();
}
// WARNING BETA better style
return (
<>
(Tout fichier dont le tailler dépasse 2mo sera refusé par le serveur)
<input
accept={this.props.type === "picture" ? "image/*" : "*"}
style={{display: "none"}}
id={this.state.id}
type="file"
onChange={(e) => this.handleChangeValue(e.target.value)}
/>
<label htmlFor={this.state.id}>
<Button variant="contained" component="span">
Fichier
</Button>
{value}
</label>
</>
);
}
}
FileField.defaultProps = {};
FileField.propTypes = {
type: PropTypes.oneOf(["picture", "file"]).isRequired
};
export default FileField;
......@@ -34,7 +34,7 @@ class UnivMapPopup extends Component {
<div style={{padding: "10px", minHeight: "70px"}}>
<MyCardMedia className={classes.media}
height={height}
url={univLogoUrl}
url={univLogoUrl === null ? "" : univLogoUrl}
title={univName} />
</div>
<Divider/>
......@@ -67,7 +67,7 @@ class UnivMapPopup extends Component {
UnivMapPopup.propTypes = {
classes: PropTypes.object.isRequired,
univLogoUrl: PropTypes.string.isRequired,
univLogoUrl: PropTypes.string,
univName: PropTypes.string.isRequired,
cityName: PropTypes.string.isRequired,
countryName: PropTypes.string.isRequired,
......
import React from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
import Paper from "@material-ui/core/Paper";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import {RequestParams} from "../../redux/api/RequestParams";
import getActions from "../../redux/api/getActions";
import {connect} from "react-redux";
import Pictures from "../user/Pictures";
import {Typography} from "@material-ui/core";
/**
* Page that lists the files available
* @class PageFiles
* @extends {React.Component}
*/
class PageFiles extends CustomComponentForAPI {
getUserIdFromUrl(props = this.props) {
return props.match.params.userId;
}
apiParams = {
files: ({props}) =>
RequestParams.Builder
.withQueryParam("owner", this.getUserIdFromUrl(props))
.build(),
pictures: ({props}) =>
RequestParams.Builder
.withQueryParam("owner", this.getUserIdFromUrl(props))
.build(),
};
customRender() {
// WARNING BETA files & padding
const {theme} = this.props,
{pictures} = this.getLatestReadDataFor(["pictures", "files"]);
return (
<>
<Paper style={theme.myPaper}>
<Typography variant={"h3"}>Photos</Typography>
<Pictures pictures={pictures} onSomethingWasSaved={() => this.props.invalidatePictures()}/>
</Paper>
{/*<Paper style={theme.myPaper}>*/}
{/* <Typography variant={"h3"}>Fichiers</Typography>*/}
{/*</Paper>*/}
</>
);
}
}
PageFiles.propTypes = {
theme: PropTypes.object.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
userId: PropTypes.string.isRequired,
})
}).isRequired,
classes: PropTypes.object.isRequired,
};
const mapStateToProps = (state) => {
return {
pictures: state.api.picturesAll,
files: state.api.filesAll,
};
};
const mapDispatchToProps = (dispatch) => {
return {
api: {
pictures: (params) => dispatch(getActions("pictures").readAll(params)),
files: (params) => dispatch(getActions("files").readAll(params)),
},
invalidatePictures: () => dispatch(getActions("pictures").invalidateAll()),
};
};
// eslint-disable-next-line no-unused-vars
const styles = theme => ({});
export default compose(
withStyles(styles, {withTheme: true}),
connect(mapStateToProps, mapDispatchToProps),
withErrorBoundary(),
)(PageFiles);
......@@ -11,7 +11,7 @@ import AttachMoneyIcon from "@material-ui/icons/AttachMoney";
import HistoryIcon from "@material-ui/icons/History";
import OfflineBoltIcon from "@material-ui/icons/OfflineBolt";
import CoverGallery from "./otherComponents/CoverGallery";
import CoverGallery from "../common/CoverGallery/CoverGallery";
import GeneralInfoTab from "./tabs/GeneralInfoTab";
// import TipsAndTricksTab from "./tabs/TipsAndTricksTab";
......
import React, { Component } from "react";
import AwesomeSlider from "react-awesome-slider";
import styles from "./CoverGallery.scss";
/**
* Component to display a cover image gallery with custom styling done in the ./CoverGallery.scss file
*
* @class CoverGallery
* @extends {React.Component}
*/
class CoverGallery extends Component {
render() {
return (
<AwesomeSlider cssModule={styles} >
<div data-src="https://www.epflalumni.ch/wp-content/uploads/2015/02/Centresportif-770x399.jpg" />
<div data-src="https://www.stcc.ch/wp-content/uploads/2017/11/epfl.jpg" />
</AwesomeSlider>
);
}
}
export default CoverGallery;
......@@ -23,10 +23,12 @@ const useStyles = makeStyles(theme => ({
flexGrow: 8,
flexShrink: 1,
marginRight: theme.spacing(2),
minWidth: "40vh",
},
wideRightCol: {
flexGrow: 6,
flexShrink: 6,
minWidth: "40vh",
},
wideColItem: {
marginBottom: theme.spacing(2),
......
import React from "react";
import withStyles from "@material-ui/core/styles/withStyles";
import compose from "recompose/compose";
import {connect} from "react-redux";
import {withSnackbar} from "notistack";
import Form from "../form/Form";
import editorStyle from "../editor/editorStyle";
import TextField from "../form/fields/TextField";
import Editor from "../editor/Editor";
import getMapStateToPropsForEditor from "../editor/getMapStateToPropsForEditor";
import getMapDispatchToPropsForEditor from "../editor/getMapDispatchToPropsForEditor";
import FileField from "../form/fields/FileField";
import PropTypes from "prop-types";
const styles = theme => ({
...editorStyle(theme)
});
class PictureForm extends Form {
render() {
return (
<>
{
this.props.includePictureField ?
<FileField type={"picture"}
label={"Image"}
{...this.getReferenceAndValue("file")}
required={true}/>
:
<></>
}
<TextField label={"Titre"}
maxLength={200}
{...this.getReferenceAndValue("title")}
required={true}/>
<TextField label={"Description"}
maxLength={500}
{...this.getReferenceAndValue("description")}
required={false}/>
<TextField label={"License"}
maxLength={100}
{...this.getReferenceAndValue("licence")}
required={false}/>
</>
);