Commit 638abfbf authored by Florent Chehab's avatar Florent Chehab

feat(more complex filters)

* Added denormalized data about semesters, majors and minors in university model
* added function to compute it
* Added function to cron
* new FilterHandler in the front to handle / cache the filtering
* Tweaked map and search components to display the right stuff
* Map now displays in a different color the elements that have been filtered

* Renamed spacilities field to majors
* Fixed bugged in downshift multiple (couldn't add same after delete)
* Deleted useless code in offer

Linked to #31
parent 048144cc
Pipeline #42724 passed with stages
in 3 minutes and 54 seconds
# Generated by Django 2.1.7 on 2019-06-30 15:53
import backend_app.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("backend_app", "0006_auto_20190630_1119")]
operations = [
migrations.RenameField(
model_name="offer", old_name="specialties", new_name="majors"
),
migrations.AddField(
model_name="university",
name="denormalized_infos",
field=backend_app.fields.JSONField(default=dict),
),
migrations.AlterField(
model_name="exchange",
name="university",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="exchanges",
to="backend_app.University",
),
),
migrations.AlterField(
model_name="offer",
name="university",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="offers",
to="backend_app.University",
),
),
]
......@@ -41,7 +41,9 @@ class Exchange(BaseModel):
student_major = models.CharField(max_length=20, null=True, blank=True)
student_semester = models.IntegerField(null=True)
partner = models.ForeignKey(Partner, on_delete=models.PROTECT, null=True)
university = models.ForeignKey(University, on_delete=models.PROTECT, null=True)
university = models.ForeignKey(
University, on_delete=models.PROTECT, null=True, related_name="exchanges"
)
# (managned by signals on course save)
student = models.ForeignKey(
User, on_delete=models.CASCADE, null=True, related_name="exchanges"
......@@ -127,7 +129,7 @@ class UnivMajorMinorsViewSet(BaseModelViewSet):
def update_denormalized_univ_major_minor():
logger.info("Computing the denormalized univ, major and minor")
data = {}
for exchange in Exchange.objects.all():
for exchange in Exchange.objects.all().prefetch_related("university"):
university = exchange.university
if university is None:
continue
......
......@@ -30,11 +30,13 @@ class Offer(BaseModel):
nb_seats_offered = models.PositiveIntegerField(null=True)
# null => exchange not possible
specialties = models.CharField(max_length=4000, null=True)
majors = models.CharField(max_length=4000, null=True)
# A bit of denormalization
partner = models.ForeignKey(Partner, on_delete=models.PROTECT, null=False)
university = models.ForeignKey(University, on_delete=models.PROTECT, null=True)
university = models.ForeignKey(
University, on_delete=models.PROTECT, null=True, related_name="offers"
)
def save(self, *args, **kwargs):
"""
......@@ -76,7 +78,7 @@ class OfferSerializer(BaseModelSerializer):
"comment",
"double_degree",
"is_master_offered",
"specialties",
"majors",
"university",
"nb_seats_offered",
)
......
import logging
from django.conf import settings
from django.db import models
from backend_app.fields import JSONField
from backend_app.models.abstract.essentialModule import (
EssentialModule,
EssentialModuleSerializer,
......@@ -8,6 +11,8 @@ from backend_app.models.abstract.essentialModule import (
)
from backend_app.validation.validators import PathExtensionValidator
logger = logging.getLogger("django")
class University(EssentialModule):
"""
......@@ -23,6 +28,9 @@ class University(EssentialModule):
)
website = models.URLField(default="", blank=True, max_length=300)
# a bit of denormalization
denormalized_infos = JSONField(default=dict)
class UniversitySerializer(EssentialModuleSerializer):
class Meta:
......@@ -32,6 +40,7 @@ class UniversitySerializer(EssentialModuleSerializer):
"acronym",
"logo",
"website",
"denormalized_infos",
)
......@@ -39,3 +48,44 @@ class UniversityViewSet(EssentialModuleViewSet):
serializer_class = UniversitySerializer
queryset = University.objects.all() # pylint: disable=E1101
end_point_route = "universities"
def update_denormalized_univ_field():
logger.info("Computing the denormalized offers/semester/major in university")
for university in University.objects.all().prefetch_related("offers", "exchanges"):
denormalized_infos = dict()
# handling of offers
for offer in university.offers.all():
semester = "{}{}".format(offer.semester, offer.year)
if semester not in denormalized_infos.keys():
denormalized_infos[semester] = dict()
majors = offer.majors
if majors is not None:
possibilities = set(
map(lambda s: s.lstrip().rstrip(), majors.split(","))
)
for major in possibilities:
# No minors in offers
denormalized_infos[semester][major] = list()
# handling of exchanges
for exchange in university.exchanges.all():
semester = "{}{}".format(exchange.semester, exchange.year)
if semester not in denormalized_infos.keys():
denormalized_infos[semester] = dict()
major = exchange.student_major
minor = exchange.student_minor
if major is not None:
if major not in denormalized_infos[semester].keys():
denormalized_infos[semester][major] = list()
if (
minor is not None
and minor not in denormalized_infos[semester][major]
):
denormalized_infos[semester][major].append(minor)
university.denormalized_infos = denormalized_infos
university.save()
logger.info("Done the denormalized offers/semester/major in university")
......@@ -15,6 +15,9 @@ 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 external_data.management.commands.utils import FixerData, UtcData # noqa: E402
from base_app.management.commands.clean_user_accounts import (
ClearUserAccounts,
......@@ -39,8 +42,9 @@ def update_utc_ent(num):
@timer(60 * 60, target="spooler") # run it every hour
@harakiri(60)
def update_univ_major_minor():
def update_extra_denormalization():
update_denormalized_univ_major_minor()
update_denormalized_univ_field()
@cron(20, 0, -1, -1, -1, target="spooler") # everyday at 20 past midnight
......
......@@ -8,6 +8,7 @@ from backend_app.models.currency import Currency
from backend_app.models.exchange import Exchange, update_denormalized_univ_major_minor
from backend_app.models.offer import Offer
from backend_app.models.partner import Partner
from backend_app.models.university import update_denormalized_univ_field
from external_data.models import ExternalDataUpdateInfo
logger = logging.getLogger("django")
......@@ -91,6 +92,7 @@ class UtcData(object):
logger.info("Updating UTC info done !")
update_denormalized_univ_major_minor()
update_denormalized_univ_field()
def __update_invalidated(self):
"""
......@@ -212,7 +214,7 @@ class UtcData(object):
nb_seats_offered=destination["nombrePlaces"],
double_degree=destination["doubleDiplome"],
is_master_offered=destination["master"],
specialties=destination["branches"],
majors=destination["branches"],
),
)
......
......@@ -123,6 +123,8 @@ class DownshiftMultiple extends React.Component {
};
handleChange = val => {
if (val === null) return; // on reset will be null
let {value} = this.state;
if (value.indexOf(val) === -1) {
value = [...value, val];
......@@ -142,8 +144,7 @@ class DownshiftMultiple extends React.Component {
handleDelete = val => () => {
this.setState(state => {
const value = [...state.value];
value.splice(value.indexOf(val), 1);
const value = state.value.filter(v => val !== v);
this.onChange(value);
return {value};
});
......@@ -167,7 +168,7 @@ class DownshiftMultiple extends React.Component {
<Downshift inputValue={inputValue}
onChange={this.handleChange}>
{
({getInputProps, getItemProps, isOpen, inputValue: inputValue2, selectedItem: selectedItem2, highlightedIndex,}) =>
({getInputProps, getItemProps, isOpen, inputValue: inputValue2, selectedItem: selectedItem2, highlightedIndex, clearSelection}) =>
(
<div className={classes.container}>
{renderInput({
......@@ -180,7 +181,10 @@ class DownshiftMultiple extends React.Component {
tabIndex={-1}
label={this.reverseOptions.get(val)}
className={classes.chip}
onDelete={this.handleDelete(val)}
onDelete={() => {
this.handleDelete(val)();
clearSelection();
}}
variant="outlined"
color="primary"
/>
......@@ -225,7 +229,7 @@ DownshiftMultiple.propTypes = {
fieldPlaceholder: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
value: PropTypes.oneOfType([PropTypes.string.isRequired, PropTypes.number.isRequired]).isRequired, // NEVER PUT A NULL
})).isRequired,
multiple: PropTypes.bool.isRequired, // do we allow multiple values to be selected.
cacheId: PropTypes.string, // if not null enable cachig of value and input Value
......
This diff is collapsed.
......@@ -64,12 +64,34 @@ class BaseMap extends Component {
map.getCanvas().style.cursor = cursor;
}
renderLayer(selected) {
const {campuses, theme} = this.props;
return (
<Layer
type="circle"
id={`campuses${selected ? "selected" : "notSelected"}`}
paint={{
"circle-color": selected ? theme.palette.primary.main : theme.palette.action.disabled,
"circle-opacity": selected ? 0.8 : 0.5,
"circle-radius": 8
}}>
{
campuses.filter(c => c.selected === selected).map(
(campusInfo, key) =>
<Feature key={key}
coordinates={[campusInfo.lon, campusInfo.lat]}
onClick={() => this.openPopup(campusInfo)}
onMouseEnter={({map}) => this.toggleHover(map, "pointer")}
onMouseLeave={({map}) => this.toggleHover(map, "")}
/>)
}
</Layer>
);
}
render() {
const {theme} = this.props,
style = this.props.theme.palette.type === "light" ?
"light"
:
"dark";
const style = this.props.theme.palette.type === "light" ? "light" : "dark";
let mapStatus = Object.assign({}, BaseMap.DEFAULTS);
......@@ -95,22 +117,10 @@ class BaseMap extends Component {
>
{
campuses ?
<Layer
type="circle"
id="campuses"
paint={{"circle-color": theme.palette.primary.main, "circle-opacity": 0.8, "circle-radius": 8}}>
{
campuses.map(
(campusInfo, key) =>
<Feature key={key}
coordinates={[campusInfo.lon, campusInfo.lat]}
onClick={() => this.openPopup(campusInfo)}
onMouseEnter={({map}) => this.toggleHover(map, "pointer")}
onMouseLeave={({map}) => this.toggleHover(map, "")}
/>)
}
</Layer>
<>
{this.renderLayer(true)}
{this.renderLayer(false)}
</>
:
<></>
}
......
......@@ -32,30 +32,29 @@ class MainMap extends CustomComponentForAPI {
citiesMap = arrayOfInstancesToMap(cities);
let mainCampusesSelection = [];
const listUnivsel = this.props.selectedUniversities;
const listUnivSel = this.props.selectedUniversities;
//console.log(this.props.selectedUniversities);
// Merge the data and add it to the selection
mainCampuses.forEach(campus => {
const univ = universitiesMap.get(campus.university);
if ((listUnivsel.length > 0 && listUnivsel.indexOf(univ.id) > -1) || listUnivsel.length === 0) {
if (campus && univ) {
const city = citiesMap.get(campus.city),
country = countriesMap.get(city.country);
mainCampusesSelection.push({
univName: univ.name,
univLogoUrl: univ.logo,
cityName: city.name,
countryName: country.name,
lat: parseFloat(campus.lat),
lon: parseFloat(campus.lon),
univId: univ.id
});
}
if (campus && univ) {
const city = citiesMap.get(campus.city),
country = countriesMap.get(city.country);
mainCampusesSelection.push({
univName: univ.name,
univLogoUrl: univ.logo,
cityName: city.name,
countryName: country.name,
lat: parseFloat(campus.lat),
lon: parseFloat(campus.lon),
univId: univ.id,
selected: listUnivSel === null || (listUnivSel.length > 0 && listUnivSel.indexOf(univ.id) > -1)
});
}
});
......@@ -71,7 +70,10 @@ MainMap.propTypes = {
mainCampuses: PropTypes.object.isRequired,
cities: PropTypes.object.isRequired,
countries: PropTypes.object.isRequired,
selectedUniversities: PropTypes.array.isRequired
selectedUniversities: PropTypes.oneOfType([
PropTypes.array.isRequired,
PropTypes.oneOf([null]).isRequired,
]),
};
const mapStateToProps = (state) => {
......
......@@ -9,6 +9,7 @@ import fuzzysort from "fuzzysort";
import UnivList from "./UnivList";
import withStyles from "@material-ui/core/styles/withStyles";
import getActions from "../../redux/api/getActions";
import Typography from "@material-ui/core/Typography";
/**
......@@ -35,7 +36,7 @@ class Search extends CustomComponentForAPI {
getSuggestions() {
const filteredUniversities = this.props.selectedUniversities,
universities = this.getLatestReadData("universities"),
possibleUniversities = filteredUniversities.length === 0 ?
possibleUniversities = filteredUniversities === null ?
universities
:
universities.filter(univ => filteredUniversities.includes(univ.id)),
......@@ -70,6 +71,15 @@ class Search extends CustomComponentForAPI {
InputProps={{classes: {input: classes.inputCentered}}}
onChange={(e) => this.setState({input: e.target.value})}
/>
{
suggestions.length === 0 ?
<Typography color={"secondary"}>
<em>
Aucune université ne correspond à la recherche.
</em>
</Typography>
: <></>
}
<UnivList universitiesToList={suggestions}/>
</>
);
......@@ -78,7 +88,10 @@ class Search extends CustomComponentForAPI {
Search.propTypes = {
classes: PropTypes.object.isRequired,
selectedUniversities: PropTypes.array.isRequired
selectedUniversities: PropTypes.oneOfType([
PropTypes.array.isRequired,
PropTypes.oneOf([null]).isRequired,
]),
};
......
......@@ -37,7 +37,7 @@ const style = theme => ({
const useStyle = makeStyles(style);
function Item(props) {
const {specialities, semester, seats, comment, master, doubleDegree} = props;
const {majors, semester, seats, comment, master, doubleDegree} = props;
const classes = useStyle();
return (
......@@ -45,7 +45,7 @@ function Item(props) {
<div>
<Chip label={semester} color={"primary"} className={classes.chip}/>
{
specialities.map(
majors.map(
spe => <Chip key={spe} label={<b>{spe}</b>} className={classes.chip} color="secondary"/>
)
}
......@@ -111,52 +111,11 @@ Item.propTypes = {
doubleDegree: PropTypes.bool.isRequired,
master: PropTypes.bool.isRequired,
semester: PropTypes.string.isRequired,
specialities: PropTypes.arrayOf(PropTypes.string).isRequired,
majors: PropTypes.arrayOf(PropTypes.string).isRequired,
comment: PropTypes.string,
seats: PropTypes.number,
};
function Core(props) {
const classes = useStyle();
return (
<Paper className={classes.paper}>
<Typography variant='h4'>Possibilité(s) d'échanges</Typography>
<Typography variant='caption'>
REX-DRI s'efforce d'être à jour avec l'ENT.
Toutefois, seul l'ENT fait foi à 100% concernant les possibilités d'échanges.
</Typography>
{
props.offers.length === 0 ?
<Typography>
<em>
Aucune offre n'est enregistrée à ce jour.
</em>
</Typography>
:
<>
{
props.offers.map(el => <Item key={el.id} {...el}/>)
}
</>
}
</Paper>
);
}
Core.propTypes = {
offers: PropTypes.arrayOf(PropTypes.shape({
doubleDegree: PropTypes.bool.isRequired,
master: PropTypes.bool.isRequired,
semester: PropTypes.string.isRequired,
specialities: PropTypes.arrayOf(PropTypes.string),
comment: PropTypes.string,
id: PropTypes.number.isRequired,
seats: PropTypes.number,
})).isRequired
};
class UniversityOffers extends CustomComponentForAPI {
state = {page: 1};
......@@ -181,7 +140,7 @@ class UniversityOffers extends CustomComponentForAPI {
is_master_offered: master,
semester,
year,
specialties,
majors,
id,
nb_seats_offered: seats,
} = dataEl;
......@@ -192,7 +151,7 @@ class UniversityOffers extends CustomComponentForAPI {
doubleDegree: doubleDegree,
comment,
master,
specialities: specialties.split(","),
majors: majors.split(","),
semester: `${semester}${year}`
};
......@@ -208,7 +167,7 @@ class UniversityOffers extends CustomComponentForAPI {
<Typography variant='h4'>Possibilité(s) d'échanges</Typography>
<Typography variant='caption'>
REX-DRI s'efforce d'être à jour avec l'ENT.
Toutefois, seul l'ENT fait foi à 100% concernant les possibilités d'échanges.
Toutefois, seul l'ENT fait foi à 100% concernant les possibilités passées et actuelles d'échanges.
</Typography>
<PaginatedData data={universityOffers}
......
......@@ -12,7 +12,9 @@ import {SAVE_SELECTED_UNIVERSITIES} from "../actions/action-types";
* @param {object} action
* @returns
*/
export function saveSelectedUniversities(state = [], action) {
export function saveSelectedUniversities(state = null, action) {
// state === null => no selection applied
// state = [...] => selection
if (action.type === SAVE_SELECTED_UNIVERSITIES) {
return action.newSelection;
} else {
......
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