From a57d9ce8e3027b2e7d527dfc4c625fedd755f9c7 Mon Sep 17 00:00:00 2001 From: "Phyks (Lucas Verney)" Date: Thu, 13 Apr 2017 23:24:31 +0200 Subject: [PATCH] Switch to a Vue-based web app * Init Webpack / Babel / etc setup. * Build the app using Vue, Vue-router, Vuex. * i18n Some backends changes were made to match the webapp development: * Return the flat status as a single string ("new" rather than "FlatStatus.new") * Completely switch to calling Weboob API directly for fetching * Use Canister for Bottle logging * Handle merging of details dict better * Add a WSGI script * Keep track of duplicates * Webserver had to be restarted to fetch external changes to the db * Handle leboncoin module better Also add contributions guidelines. Closes issue #3 Closes issue #14. --- .babelrc | 4 + .eslintrc | 10 + .gitignore | 4 +- CONTRIBUTING.md | 46 +++ README.md | 5 + flatisfy/__main__.py | 10 + flatisfy/cmds.py | 42 ++- flatisfy/config.py | 41 ++- flatisfy/data.py | 3 +- flatisfy/database/__init__.py | 4 +- flatisfy/database/types.py | 2 +- flatisfy/fetch.py | 188 ++++++++++-- flatisfy/filters/__init__.py | 15 +- flatisfy/filters/duplicates.py | 27 +- flatisfy/filters/metadata.py | 20 +- flatisfy/models/flat.py | 64 +++- flatisfy/tools.py | 70 +++-- flatisfy/web/app.py | 35 +++ flatisfy/web/configplugin.py | 72 +++++ flatisfy/web/dbplugin.py | 17 +- flatisfy/web/js_src/api/index.js | 63 ++++ flatisfy/web/js_src/components/app.vue | 76 +++++ flatisfy/web/js_src/components/flatsmap.vue | 103 +++++++ flatisfy/web/js_src/components/flatstable.vue | 90 ++++++ flatisfy/web/js_src/components/slider.vue | 144 +++++++++ flatisfy/web/js_src/i18n/en/index.js | 55 ++++ flatisfy/web/js_src/i18n/index.js | 52 ++++ flatisfy/web/js_src/main.js | 14 + flatisfy/web/js_src/router/index.js | 18 ++ flatisfy/web/js_src/store/actions.js | 26 ++ flatisfy/web/js_src/store/getters.js | 56 ++++ flatisfy/web/js_src/store/index.js | 16 + flatisfy/web/js_src/store/mutations-types.js | 4 + flatisfy/web/js_src/store/mutations.js | 34 +++ flatisfy/web/js_src/tools/index.js | 21 ++ flatisfy/web/js_src/views/details.vue | 275 ++++++++++++++++++ flatisfy/web/js_src/views/home.vue | 52 ++++ flatisfy/web/js_src/views/status.vue | 41 +++ flatisfy/web/routes/api.py | 119 +++++++- flatisfy/web/static/index.html | 26 +- .../js/9e9c77db241e8a58da99bf28694c907d.png | Bin 0 -> 535 bytes .../js/a159160a02a1caf189f5008c0d150f36.png | Bin 0 -> 16288 bytes .../js/c6477ef24ce2054017fce1a2d8800385.png | Bin 0 -> 8005 bytes .../js/d3a5d64a8534322988a4bed1b7dbc8b0.png | Bin 0 -> 1469 bytes hooks/pre-commit | 1 + package.json | 51 ++++ requirements.txt | 2 + webpack.config.js | 55 ++++ wsgi.py | 33 +++ 49 files changed, 1987 insertions(+), 119 deletions(-) create mode 100644 .babelrc create mode 100644 .eslintrc create mode 100644 CONTRIBUTING.md create mode 100644 flatisfy/web/configplugin.py create mode 100644 flatisfy/web/js_src/api/index.js create mode 100644 flatisfy/web/js_src/components/app.vue create mode 100644 flatisfy/web/js_src/components/flatsmap.vue create mode 100644 flatisfy/web/js_src/components/flatstable.vue create mode 100644 flatisfy/web/js_src/components/slider.vue create mode 100644 flatisfy/web/js_src/i18n/en/index.js create mode 100644 flatisfy/web/js_src/i18n/index.js create mode 100644 flatisfy/web/js_src/main.js create mode 100644 flatisfy/web/js_src/router/index.js create mode 100644 flatisfy/web/js_src/store/actions.js create mode 100644 flatisfy/web/js_src/store/getters.js create mode 100644 flatisfy/web/js_src/store/index.js create mode 100644 flatisfy/web/js_src/store/mutations-types.js create mode 100644 flatisfy/web/js_src/store/mutations.js create mode 100644 flatisfy/web/js_src/tools/index.js create mode 100644 flatisfy/web/js_src/views/details.vue create mode 100644 flatisfy/web/js_src/views/home.vue create mode 100644 flatisfy/web/js_src/views/status.vue create mode 100644 flatisfy/web/static/js/9e9c77db241e8a58da99bf28694c907d.png create mode 100644 flatisfy/web/static/js/a159160a02a1caf189f5008c0d150f36.png create mode 100644 flatisfy/web/static/js/c6477ef24ce2054017fce1a2d8800385.png create mode 100644 flatisfy/web/static/js/d3a5d64a8534322988a4bed1b7dbc8b0.png create mode 100644 package.json create mode 100644 webpack.config.js create mode 100644 wsgi.py diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..e8c66d1 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015", "stage-0"], + "plugins": ["transform-runtime"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..c2bac62 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,10 @@ +{ + extends: ["vue", /* your other extends */], + plugins: ["vue"], + "env": { + "browser": true + }, + rules: { + 'indent': ["error", 4, { 'SwitchCase': 1 }], + } +} diff --git a/.gitignore b/.gitignore index 5d0799b..93c23ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ build -*.json *.pyc *.swp *.swo *.db +config/ +node_modules +flatisfy/web/static/js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..df12db1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +## TL;DR + +* Branch off `master`. +* One feature per commit. +* In case of changes request, amend your commit. + + +## Useful infos + +* There is a `hooks/pre-commit` file which can be used as a `pre-commit` git + hook to check coding style. +* Python coding style is PEP8. JS coding style is enforced by `eslint`. +* Some useful `npm` scripts are provided (`build` / `watch` / `lint`) + + +## Translating the webapp + +If you want to translate the webapp, just create a new folder in +`flatisfy/web/js_src/i18n` with the short name of your locale (typically, `en` +is for english). Copy the `flatisfy/web/js_src/i18n/en/index.js` file to this +new folder and translate the `messages` strings. + +Then, edit `flatisfy/web/js_src/i18n/index.js` file to include your new +locale. + + +## How to contribute + +* If you're thinking about a new feature, see if there's already an issue open + about it, or please open one otherwise. This will ensure that everybody is on + track for the feature and willing to see it in Flatisfy. +* One commit per feature. +* Branch off the `master ` branch. +* Check the linting of your code before doing a PR. +* Ideally, your merge-request should be mergeable without any merge commit, that + is, it should be a fast-forward merge. For this to happen, your code needs to + be always rebased onto `master`. Again, this is something nice to have that + I expect from recurring contributors, but not a big deal if you don't do it + otherwise. +* I'll look at it and might ask for a few changes. In this case, please create + new commits. When the final result looks good, I may ask you to squash the + WIP commits into a single one, to maintain the invariant of "one feature, one + commit". + + +Thanks! diff --git a/README.md b/README.md index b57b4c3..593814e 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,11 @@ The content of this repository is licensed under an MIT license, unless explicitly mentionned otherwise. +## Contributing + +See the `CONTRIBUTING.md` file for more infos. + + ## Thanks * [Weboob](http://weboob.org/) diff --git a/flatisfy/__main__.py b/flatisfy/__main__.py index 0641e16..2229173 100644 --- a/flatisfy/__main__.py +++ b/flatisfy/__main__.py @@ -90,6 +90,10 @@ def parse_args(argv=None): subparsers.add_parser("import", parents=[parent_parser], help="Import housing posts in database.") + # Purge subcommand parser + subparsers.add_parser("purge", parents=[parent_parser], + help="Purge database.") + # Serve subcommand parser parser_serve = subparsers.add_parser("serve", parents=[parent_parser], help="Serve the web app.") @@ -103,6 +107,7 @@ def main(): """ Main module code. """ + # pylint: disable=locally-disabled,too-many-branches # Parse arguments args = parse_args() @@ -163,7 +168,12 @@ def main(): ) # Import command elif args.cmd == "import": + # TODO: Do not fetch details for already imported flats / use the last + # timestamp cmds.import_and_filter(config) + # Purge command + elif args.cmd == "purge": + cmds.purge_db(config) # Serve command elif args.cmd == "serve": cmds.serve(config) diff --git a/flatisfy/cmds.py b/flatisfy/cmds.py index 08d7aee..f91678f 100644 --- a/flatisfy/cmds.py +++ b/flatisfy/cmds.py @@ -4,6 +4,8 @@ Main commands available for flatisfy. """ from __future__ import absolute_import, print_function, unicode_literals +import logging + import flatisfy.filters from flatisfy import database from flatisfy.models import flat as flat_model @@ -12,6 +14,9 @@ from flatisfy import tools from flatisfy.web import app as web_app +LOGGER = logging.getLogger(__name__) + + def fetch_and_filter(config): """ Fetch the available flats list. Then, filter it according to criteria. @@ -34,9 +39,9 @@ def fetch_and_filter(config): # additional infos if config["passes"] > 1: # Load additional infos - for flat in flats_list: - details = fetch.fetch_details(flat["id"]) - flat = tools.merge_dicts(flat, details) + for i, flat in enumerate(flats_list): + details = fetch.fetch_details(config, flat["id"]) + flats_list[i] = tools.merge_dicts(flat, details) flats_list, extra_ignored_flats = flatisfy.filters.second_pass( flats_list, config @@ -83,7 +88,7 @@ def import_and_filter(config): :return: ``None``. """ # Fetch and filter flats list - flats_list, purged_list = fetch_and_filter(config) + flats_list, ignored_list = fetch_and_filter(config) # Create database connection get_session = database.init_db(config["database"]) @@ -92,12 +97,27 @@ def import_and_filter(config): flat = flat_model.Flat.from_dict(flat_dict) session.merge(flat) - for flat_dict in purged_list: + for flat_dict in ignored_list: flat = flat_model.Flat.from_dict(flat_dict) - flat.status = flat_model.FlatStatus.purged + flat.status = flat_model.FlatStatus.ignored session.merge(flat) +def purge_db(config): + """ + Purge the database. + + :param config: A config dict. + :return: ``None`` + """ + get_session = database.init_db(config["database"]) + + with get_session() as session: + # Delete every flat in the db + LOGGER.info("Purge all flats from the database.") + session.query(flat_model.Flat).delete(synchronize_session=False) + + def serve(config): """ Serve the web app. @@ -106,5 +126,11 @@ def serve(config): :return: ``None``, long-running process. """ app = web_app.get_app(config) - # TODO: Make Bottle use logging module - app.run(host=config["host"], port=config["port"]) + + server = config.get("webserver", None) + if not server: + # Default webserver is quiet, as Bottle is used with Canister for + # standard logging + server = web_app.QuietWSGIRefServer + + app.run(host=config["host"], port=config["port"], server=server) diff --git a/flatisfy/config.py b/flatisfy/config.py index 35e694d..b9693ca 100644 --- a/flatisfy/config.py +++ b/flatisfy/config.py @@ -21,10 +21,11 @@ from flatisfy import tools # Default configuration DEFAULT_CONFIG = { - # Flatboob queries to fetch - "queries": [], # Constraints to match "constraints": { + "type": None, # RENT, SALE, SHARING + "house_types": [], # List of house types, must be in APART, HOUSE, + # PARKING, LAND, OTHER or UNKNOWN "postal_codes": [], # List of postal codes "area": (None, None), # (min, max) in m^2 "cost": (None, None), # (min, max) in currency unit @@ -42,12 +43,18 @@ DEFAULT_CONFIG = { "max_entries": None, # Directory in wich data will be put. ``None`` is XDG default location. "data_directory": None, + # Path to the modules directory containing all Weboob modules. ``None`` if + # ``weboob_modules`` package is pip-installed, and you want to use + # ``pkgresource`` to automatically find it. + "modules_path": None, # SQLAlchemy URI to the database to use "database": None, # Web app port "port": 8080, # Web app host to listen on - "host": "127.0.0.1" + "host": "127.0.0.1", + # Web server to use to serve the webapp (see Bottle deployment doc) + "webserver": None } LOGGER = logging.getLogger(__name__) @@ -68,7 +75,7 @@ def validate_config(config): assert all( x is None or ( - (isinstance(x, int) or isinstance(x, float)) and + isinstance(x, (float, int)) and x >= 0 ) for x in bounds @@ -81,9 +88,19 @@ def validate_config(config): # Then, we disable line-too-long pylint check and E501 flake8 checks # and use long lines whenever needed, in order to have the full assert # message in the log output. - # pylint: disable=line-too-long + # pylint: disable=locally-disabled,line-too-long + assert "type" in config["constraints"] + assert config["constraints"]["type"].upper() in ["RENT", + "SALE", "SHARING"] + + assert "house_types" in config["constraints"] + assert config["constraints"]["house_types"] + for house_type in config["constraints"]["house_types"]: + assert house_type.upper() in ["APART", "HOUSE", "PARKING", "LAND", + "OTHER", "UNKNOWN"] + assert "postal_codes" in config["constraints"] - assert len(config["constraints"]["postal_codes"]) > 0 + assert config["constraints"]["postal_codes"] assert "area" in config["constraints"] _check_constraints_bounds(config["constraints"]["area"]) @@ -111,11 +128,13 @@ def validate_config(config): assert config["max_entries"] is None or (isinstance(config["max_entries"], int) and config["max_entries"] > 0) # noqa: E501 assert config["data_directory"] is None or isinstance(config["data_directory"], str) # noqa: E501 + assert config["modules_path"] is None or isinstance(config["modules_path"], str) # noqa: E501 assert config["database"] is None or isinstance(config["database"], str) # noqa: E501 assert isinstance(config["port"], int) assert isinstance(config["host"], str) + assert config["webserver"] is None or isinstance(config["webserver"], str) # noqa: E501 return True except (AssertionError, KeyError): @@ -140,10 +159,11 @@ def load_config(args=None): try: with open(args.config, "r") as fh: config_data.update(json.load(fh)) - except (IOError, ValueError): + except (IOError, ValueError) as exc: LOGGER.error( "Unable to load configuration from file, " - "using default configuration." + "using default configuration: %s.", + exc ) # Overload config with arguments @@ -188,9 +208,8 @@ def load_config(args=None): if config_validation is True: LOGGER.info("Config has been fully initialized.") return config_data - else: - LOGGER.error("Error in configuration: %s.", config_validation) - return None + LOGGER.error("Error in configuration: %s.", config_validation) + return None def init_config(output=None): diff --git a/flatisfy/data.py b/flatisfy/data.py index 5f766c6..807cce8 100644 --- a/flatisfy/data.py +++ b/flatisfy/data.py @@ -9,6 +9,7 @@ import collections import json import logging import os +import shutil import flatisfy.exceptions @@ -157,7 +158,7 @@ def load_data(data_type, config): LOGGER.error("Invalid JSON data file: %s.", datafile_path) return None - if len(data) == 0: + if not data: LOGGER.warning("Loading empty data for %s.", data_type) return data diff --git a/flatisfy/database/__init__.py b/flatisfy/database/__init__.py index 06819a3..b23e1c7 100644 --- a/flatisfy/database/__init__.py +++ b/flatisfy/database/__init__.py @@ -41,16 +41,18 @@ def init_db(database_uri=None): engine = create_engine(database_uri) BASE.metadata.create_all(engine, checkfirst=True) - Session = sessionmaker(bind=engine) # pylint: disable=invalid-name + Session = sessionmaker(bind=engine) # pylint: disable=locally-disabled,invalid-name @contextmanager def get_session(): + # pylint: disable=locally-disabled,line-too-long """ Provide a transactional scope around a series of operations. From [1]. [1]: http://docs.sqlalchemy.org/en/latest/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it. """ + # pylint: enable=line-too-long,locally-disabled session = Session() try: yield session diff --git a/flatisfy/database/types.py b/flatisfy/database/types.py index d55d7bb..ea2afdc 100644 --- a/flatisfy/database/types.py +++ b/flatisfy/database/types.py @@ -46,5 +46,5 @@ class StringyJSON(types.TypeDecorator): # TypeEngine.with_variant says "use StringyJSON instead when # connecting to 'sqlite'" -# pylint: disable=invalid-name +# pylint: disable=locally-disabled,invalid-name MagicJSON = types.JSON().with_variant(StringyJSON, 'sqlite') diff --git a/flatisfy/fetch.py b/flatisfy/fetch.py index addf2d3..7fea23b 100644 --- a/flatisfy/fetch.py +++ b/flatisfy/fetch.py @@ -4,14 +4,159 @@ This module contains all the code related to fetching and loading flats lists. """ from __future__ import absolute_import, print_function, unicode_literals +import itertools import json import logging -import subprocess +from flatisfy import data +from flatisfy import tools LOGGER = logging.getLogger(__name__) +try: + from weboob.capabilities.housing import Query + from weboob.core.ouiboube import WebNip + from weboob.tools.json import WeboobEncoder +except ImportError: + LOGGER.error("Weboob is not available on your system. Make sure you " + "installed it.") + raise + + +class WeboobProxy(object): + """ + Wrapper around Weboob ``WebNip`` class, to fetch housing posts without + having to spawn a subprocess. + """ + @staticmethod + def version(): + """ + Get Weboob version. + + :return: The installed Weboob version. + """ + return WebNip.VERSION + + def __init__(self, config): + """ + Create a Weboob handle and try to load the modules. + + :param config: A config dict. + """ + # Create base WebNip object + self.webnip = WebNip(modules_path=config["modules_path"]) + + # Create backends + self.backends = [ + self.webnip.load_backend( + module, + module, + params={} + ) + for module in ["seloger", "pap", "leboncoin", "logicimmo", + "explorimmo", "entreparticuliers"] + ] + + def __enter__(self): + return self + + def __exit__(self, *args): + self.webnip.deinit() + + def build_queries(self, constraints_dict): + """ + Build Weboob ``weboob.capabilities.housing.Query`` objects from the + constraints defined in the configuration. Each query has at most 3 + postal codes, to comply with housing websites limitations. + + :param constraints_dict: A dictionary of constraints, as defined in the + config. + :return: A list of Weboob ``weboob.capabilities.housing.Query`` + objects. Returns ``None`` if an error occurred. + """ + queries = [] + for postal_codes in tools.batch(constraints_dict["postal_codes"], 3): + query = Query() + query.cities = [] + for postal_code in postal_codes: + try: + for city in self.webnip.do("search_city", postal_code): + query.cities.append(city) + except IndexError: + LOGGER.error( + "Postal code %s could not be matched with a city.", + postal_code + ) + return None + + try: + query.house_types = [ + getattr( + Query.HOUSE_TYPES, + house_type.upper() + ) + for house_type in constraints_dict["house_types"] + ] + except AttributeError: + LOGGER.error("Invalid house types constraint.") + return None + + try: + query.type = getattr( + Query, + "TYPE_{}".format(constraints_dict["type"].upper()) + ) + except AttributeError: + LOGGER.error("Invalid post type constraint.") + return None + + query.area_min = constraints_dict["area"][0] + query.area_max = constraints_dict["area"][1] + query.cost_min = constraints_dict["cost"][0] + query.cost_max = constraints_dict["cost"][1] + query.nb_rooms = constraints_dict["rooms"][0] + + queries.append(query) + + return queries + + def query(self, query, max_entries=None): + """ + Fetch the housings posts matching a given Weboob query. + + :param query: A Weboob `weboob.capabilities.housing.Query`` object. + :param max_entries: Maximum number of entries to fetch. + :return: The matching housing posts, dumped as a list of JSON objects. + """ + housings = [] + # TODO: Handle max_entries better + for housing in itertools.islice( + self.webnip.do('search_housings', query), + max_entries + ): + housings.append(json.dumps(housing, cls=WeboobEncoder)) + return housings + + def info(self, full_flat_id): + """ + Get information (details) about an housing post. + + :param full_flat_id: A Weboob housing post id, in complete form + (ID@BACKEND) + :return: The details in JSON. + """ + flat_id, backend_name = full_flat_id.rsplit("@", 1) + backend = next( + backend + for backend in self.backends + if backend.name == backend_name + ) + housing = backend.get_housing(flat_id) + housing.id = full_flat_id # Otherwise, we miss the @backend afterwards + return json.dumps(housing, cls=WeboobEncoder) + + def fetch_flats_list(config): """ Fetch the available flats using the Flatboob / Weboob config. @@ -20,40 +165,35 @@ def fetch_flats_list(config): :return: A list of all available flats. """ flats_list = [] - for query in config["queries"]: - max_entries = config["max_entries"] - if max_entries is None: - max_entries = 0 - LOGGER.info("Loading flats from query %s.", query) - flatboob_output = subprocess.check_output( - ["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob", - "-n", str(max_entries), "-f", "json", "load", query] - ) - query_flats_list = json.loads(flatboob_output) - LOGGER.info("Fetched %d flats.", len(query_flats_list)) - flats_list.extend(query_flats_list) - LOGGER.info("Fetched a total of %d flats.", len(flats_list)) + with WeboobProxy(config) as weboob_proxy: + LOGGER.info("Loading flats...") + queries = weboob_proxy.build_queries(config["constraints"]) + housing_posts = [] + for query in queries: + housing_posts.extend( + weboob_proxy.query(query, config["max_entries"]) + ) + LOGGER.info("Fetched %d flats.", len(housing_posts)) + + flats_list = [json.loads(flat) for flat in housing_posts] return flats_list -def fetch_details(flat_id): +def fetch_details(config, flat_id): """ Fetch the additional details for a flat using Flatboob / Weboob. + :param config: A config dict. :param flat_id: ID of the flat to fetch details for. :return: A flat dict with all the available data. """ - LOGGER.info("Loading additional details for flat %s.", flat_id) - flatboob_output = subprocess.check_output( - ["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob", - "-f", "json", "info", flat_id] - ) - flat_details = json.loads(flatboob_output) - LOGGER.info("Fetched details for flat %s.", flat_id) + with WeboobProxy(config) as weboob_proxy: + LOGGER.info("Loading additional details for flat %s.", flat_id) + weboob_output = weboob_proxy.info(flat_id) - if flat_details: - flat_details = flat_details[0] + flat_details = json.loads(weboob_output) + LOGGER.info("Fetched details for flat %s.", flat_id) return flat_details diff --git a/flatisfy/filters/__init__.py b/flatisfy/filters/__init__.py index 8addc68..2220a8b 100644 --- a/flatisfy/filters/__init__.py +++ b/flatisfy/filters/__init__.py @@ -89,9 +89,10 @@ def first_pass(flats_list, config): :param flats_list: A list of flats dict to filter. :param config: A config dict. - :return: A tuple of processed flats and purged flats. + :return: A tuple of processed flats and ignored flats. """ LOGGER.info("Running first filtering pass.") + # Handle duplicates based on ids # Just remove them (no merge) as they should be the exact same object. flats_list = duplicates.detect( @@ -105,16 +106,16 @@ def first_pass(flats_list, config): flats_list, key="url", merge=True ) - # Add the flatisfy metadata entry + # Add the flatisfy metadata entry and prepare the flat objects flats_list = metadata.init(flats_list) # Guess the postal codes flats_list = metadata.guess_postal_code(flats_list, config) # Try to match with stations flats_list = metadata.guess_stations(flats_list, config) # Remove returned housing posts that do not match criteria - flats_list, purged_list = refine_with_housing_criteria(flats_list, config) + flats_list, ignored_list = refine_with_housing_criteria(flats_list, config) - return (flats_list, purged_list) + return (flats_list, ignored_list) def second_pass(flats_list, config): @@ -130,7 +131,7 @@ def second_pass(flats_list, config): :param flats_list: A list of flats dict to filter. :param config: A config dict. - :return: A tuple of processed flats and purged flats. + :return: A tuple of processed flats and ignored flats. """ LOGGER.info("Running second filtering pass.") # Assumed to run after first pass, so there should be no obvious duplicates @@ -148,6 +149,6 @@ def second_pass(flats_list, config): flats_list = metadata.compute_travel_times(flats_list, config) # Remove returned housing posts that do not match criteria - flats_list, purged_list = refine_with_housing_criteria(flats_list, config) + flats_list, ignored_list = refine_with_housing_criteria(flats_list, config) - return (flats_list, purged_list) + return (flats_list, ignored_list) diff --git a/flatisfy/filters/duplicates.py b/flatisfy/filters/duplicates.py index 18f9264..a7365c3 100644 --- a/flatisfy/filters/duplicates.py +++ b/flatisfy/filters/duplicates.py @@ -5,9 +5,23 @@ Filtering functions to detect and merge duplicates. from __future__ import absolute_import, print_function, unicode_literals import collections +import logging from flatisfy import tools +LOGGER = logging.getLogger(__name__) + +# Some backends give more infos than others. Here is the precedence we want to +# use. +BACKENDS_PRECEDENCE = [ + "seloger", + "pap", + "leboncoin", + "explorimmo", + "logicimmo", + "entreparticuliers" +] + def detect(flats_list, key="id", merge=True): """ @@ -27,7 +41,6 @@ def detect(flats_list, key="id", merge=True): :return: A deduplicated list of flat dicts. """ - # TODO: Keep track of found duplicates? # ``seen`` is a dict mapping aggregating the flats by the deduplication # keys. We basically make buckets of flats for every key value. Flats in # the same bucket should be merged together afterwards. @@ -44,6 +57,18 @@ def detect(flats_list, key="id", merge=True): # of the others, to avoid over-deduplication. unique_flats_list.extend(matching_flats) else: + # Sort matching flats by backend precedence + matching_flats.sort( + key=lambda flat: next( + i for (i, backend) in enumerate(BACKENDS_PRECEDENCE) + if flat["id"].endswith(backend) + ), + reverse=True + ) + + if len(matching_flats) > 1: + LOGGER.info("Found duplicates: %s.", + [flat["id"] for flat in matching_flats]) # Otherwise, check the policy if merge: # If a merge is requested, do the merge diff --git a/flatisfy/filters/metadata.py b/flatisfy/filters/metadata.py index 002934d..e52050e 100644 --- a/flatisfy/filters/metadata.py +++ b/flatisfy/filters/metadata.py @@ -20,14 +20,20 @@ LOGGER = logging.getLogger(__name__) def init(flats_list): """ Create a flatisfy key containing a dict of metadata fetched by flatisfy for - each flat in the list. + each flat in the list. Also perform some basic transform on flat objects to + prepare for the metadata fetching. :param flats_list: A list of flats dict. :return: The updated list """ for flat in flats_list: + # Init flatisfy key if "flatisfy" not in flat: flat["flatisfy"] = {} + # Move url key to urls + flat["urls"] = [flat["url"]] + # Create merged_ids key + flat["merged_ids"] = [flat["id"]] return flats_list @@ -298,11 +304,17 @@ def guess_stations(flats_list, config, distance_threshold=1500): # If some stations were already filled in and the result is different, # display some warning to the user if ( - "matched_stations" in flat["flatisfy"]["matched_stations"] and + "matched_stations" in flat["flatisfy"] and ( # Do a set comparison, as ordering is not important - set(flat["flatisfy"]["matched_stations"]) != - set(good_matched_stations) + set([ + station["name"] + for station in flat["flatisfy"]["matched_stations"] + ]) != + set([ + station["name"] + for station in good_matched_stations + ]) ) ): LOGGER.warning( diff --git a/flatisfy/models/flat.py b/flatisfy/models/flat.py index f5d440d..12d2c74 100644 --- a/flatisfy/models/flat.py +++ b/flatisfy/models/flat.py @@ -2,9 +2,12 @@ """ This modules defines an SQLAlchemy ORM model for a flat. """ -# pylint: disable=invalid-name,too-few-public-methods +# pylint: disable=locally-disabled,invalid-name,too-few-public-methods from __future__ import absolute_import, print_function, unicode_literals +import logging + +import arrow import enum from sqlalchemy import Column, DateTime, Enum, Float, String, Text @@ -13,15 +16,29 @@ from flatisfy.database.base import BASE from flatisfy.database.types import MagicJSON +LOGGER = logging.getLogger(__name__) + + +class FlatUtilities(enum.Enum): + """ + An enum of the possible utilities status for a flat entry. + """ + included = 10 + unknown = 0 + excluded = -10 + + class FlatStatus(enum.Enum): """ An enum of the possible status for a flat entry. """ - purged = -10 + user_deleted = -100 + ignored = -10 new = 0 - contacted = 10 - answer_no = 20 - answer_yes = 21 + followed = 10 + contacted = 20 + answer_no = 30 + answer_yes = 31 class Flat(BASE): @@ -36,6 +53,7 @@ class Flat(BASE): bedrooms = Column(Float) cost = Column(Float) currency = Column(String) + utilities = Column(Enum(FlatUtilities), default=FlatUtilities.unknown) date = Column(DateTime) details = Column(MagicJSON) location = Column(String) @@ -45,7 +63,8 @@ class Flat(BASE): station = Column(String) text = Column(Text) title = Column(String) - url = Column(String) + urls = Column(MagicJSON) + merged_ids = Column(MagicJSON) # Flatisfy data # TODO: Should be in another table with relationships @@ -65,25 +84,45 @@ class Flat(BASE): # Handle flatisfy metadata flat_dict = flat_dict.copy() flat_dict["flatisfy_stations"] = ( - flat_dict["flatisfy"].get("matched_stations", None) + flat_dict["flatisfy"].get("matched_stations", []) ) flat_dict["flatisfy_postal_code"] = ( flat_dict["flatisfy"].get("postal_code", None) ) flat_dict["flatisfy_time_to"] = ( - flat_dict["flatisfy"].get("time_to", None) + flat_dict["flatisfy"].get("time_to", {}) ) del flat_dict["flatisfy"] + # Handle utilities field + if not isinstance(flat_dict["utilities"], FlatUtilities): + if flat_dict["utilities"] == "C.C.": + flat_dict["utilities"] = FlatUtilities.included + elif flat_dict["utilities"] == "H.C.": + flat_dict["utilities"] = FlatUtilities.excluded + else: + flat_dict["utilities"] = FlatUtilities.unknown + + # Handle status field + flat_status = flat_dict.get("status", "new") + if not isinstance(flat_status, FlatStatus): + try: + flat_dict["status"] = getattr(FlatStatus, flat_status) + except AttributeError: + if "status" in flat_dict: + del flat_dict["status"] + LOGGER.warn("Unkown flat status %s, ignoring it.", + flat_status) + # Handle date field - flat_dict["date"] = None # TODO + flat_dict["date"] = arrow.get(flat_dict["date"]).naive flat_object = Flat() flat_object.__dict__.update(flat_dict) return flat_object def __repr__(self): - return "" % (self.id, self.url) + return "" % (self.id, self.urls) def json_api_repr(self): @@ -96,6 +135,9 @@ class Flat(BASE): for k, v in self.__dict__.items() if not k.startswith("_") } - flat_repr["status"] = str(flat_repr["status"]) + if isinstance(flat_repr["status"], FlatStatus): + flat_repr["status"] = flat_repr["status"].name + if isinstance(flat_repr["utilities"], FlatUtilities): + flat_repr["utilities"] = flat_repr["utilities"].name return flat_repr diff --git a/flatisfy/tools.py b/flatisfy/tools.py index f714286..ecc78e4 100644 --- a/flatisfy/tools.py +++ b/flatisfy/tools.py @@ -8,6 +8,7 @@ from __future__ import ( ) import datetime +import itertools import json import logging import math @@ -23,6 +24,16 @@ LOGGER = logging.getLogger(__name__) NAVITIA_ENDPOINT = "https://api.navitia.io/v1/coverage/fr-idf/journeys" +class DateAwareJSONEncoder(json.JSONEncoder): + """ + Extend the default JSON encoder to serialize datetimes to iso strings. + """ + def default(self, o): # pylint: disable=locally-disabled,E0202 + if isinstance(o, (datetime.date, datetime.datetime)): + return o.isoformat() + return json.JSONEncoder.default(self, o) + + def pretty_json(data): """ Pretty JSON output. @@ -38,10 +49,25 @@ def pretty_json(data): "toto": "ok" } """ - return json.dumps(data, indent=4, separators=(',', ': '), + return json.dumps(data, cls=DateAwareJSONEncoder, + indent=4, separators=(',', ': '), sort_keys=True) +def batch(iterable, size): + """ + Get items from a sequence a batch at a time. + + :param iterable: The iterable to get the items from. + :param size: The size of the batches. + :return: A new iterable. + """ + sourceiter = iter(iterable) + while True: + batchiter = itertools.islice(sourceiter, size) + yield itertools.chain([batchiter.next()], batchiter) + + def is_within_interval(value, min_value=None, max_value=None): """ Check whether a variable is within a given interval. Assumes the value is @@ -142,7 +168,7 @@ def distance(gps1, gps2): lat2 = math.radians(gps2[0]) long2 = math.radians(gps2[1]) - # pylint: disable=invalid-name + # pylint: disable=locally-disabled,invalid-name a = ( math.sin((lat2 - lat1) / 2.0)**2 + math.cos(lat1) * math.cos(lat2) * math.sin((long2 - long1) / 2.0)**2 @@ -175,22 +201,30 @@ def merge_dicts(*args): """ if len(args) == 1: return args[0] - else: - flat1, flat2 = args[:2] - merged_flat = {} - for k, value2 in flat2.items(): - value1 = flat1.get(k, None) - if value1 is None: - # flat1 has empty matching field, just keep the flat2 field - merged_flat[k] = value2 - elif value2 is None: - # flat2 field is empty, just keep the flat1 field - merged_flat[k] = value1 - else: - # Any other case, we should merge - # TODO: Do the merge - merged_flat[k] = value1 - return merge_dicts(merged_flat, *args[2:]) + + flat1, flat2 = args[:2] # pylint: disable=locally-disabled,unbalanced-tuple-unpacking,line-too-long + merged_flat = {} + for k, value2 in flat2.items(): + value1 = flat1.get(k, None) + + if k in ["urls", "merged_ids"]: + # Handle special fields separately + merged_flat[k] = list(set(value2 + value1)) + continue + + if not value1: + # flat1 has empty matching field, just keep the flat2 field + merged_flat[k] = value2 + elif not value2: + # flat2 field is empty, just keep the flat1 field + merged_flat[k] = value1 + else: + # Any other case, we should keep the value of the more recent flat + # dict (the one most at right in arguments) + merged_flat[k] = value2 + for k in [key for key in flat1.keys() if key not in flat2.keys()]: + merged_flat[k] = flat1[k] + return merge_dicts(merged_flat, *args[2:]) def get_travel_time_between(latlng_from, latlng_to, config): diff --git a/flatisfy/web/app.py b/flatisfy/web/app.py index 347bfb4..46dad4c 100644 --- a/flatisfy/web/app.py +++ b/flatisfy/web/app.py @@ -6,15 +6,30 @@ from __future__ import ( absolute_import, division, print_function, unicode_literals ) +import functools +import json +import logging import os import bottle +import canister from flatisfy import database +from flatisfy.tools import DateAwareJSONEncoder from flatisfy.web.routes import api as api_routes +from flatisfy.web.configplugin import ConfigPlugin from flatisfy.web.dbplugin import DatabasePlugin +class QuietWSGIRefServer(bottle.WSGIRefServer): + """ + Quiet implementation of Bottle built-in WSGIRefServer, as `Canister` is + handling the logging through standard Python logging. + """ + # pylint: disable=locally-disabled,too-few-public-methods + quiet = True + + def _serve_static_file(filename): """ Helper function to serve static file. @@ -38,11 +53,31 @@ def get_app(config): app = bottle.default_app() app.install(DatabasePlugin(get_session)) + app.install(ConfigPlugin(config)) + app.config.setdefault("canister.log_level", logging.root.level) + app.config.setdefault("canister.log_path", None) + app.config.setdefault("canister.debug", False) + app.install(canister.Canister()) + # Use DateAwareJSONEncoder to dump JSON strings + # From http://stackoverflow.com/questions/21282040/bottle-framework-how-to-return-datetime-in-json-response#comment55718456_21282666. pylint: disable=locally-disabled,line-too-long + bottle.install( + bottle.JSONPlugin( + json_dumps=functools.partial(json.dumps, cls=DateAwareJSONEncoder) + ) + ) # API v1 routes app.route("/api/v1/", "GET", api_routes.index_v1) + + app.route("/api/v1/time_to/places", "GET", api_routes.time_to_places_v1) + app.route("/api/v1/flats", "GET", api_routes.flats_v1) + app.route("/api/v1/flats/status/:status", "GET", + api_routes.flats_by_status_v1) + app.route("/api/v1/flat/:flat_id", "GET", api_routes.flat_v1) + app.route("/api/v1/flat/:flat_id/status", "POST", + api_routes.update_flat_status_v1) # Index app.route("/", "GET", lambda: _serve_static_file("index.html")) diff --git a/flatisfy/web/configplugin.py b/flatisfy/web/configplugin.py new file mode 100644 index 0000000..68d8a04 --- /dev/null +++ b/flatisfy/web/configplugin.py @@ -0,0 +1,72 @@ +# coding: utf-8 +""" +This module contains a Bottle plugin to pass the config argument to any route +which needs it. + +This module is heavily based on code from +[Bottle-SQLAlchemy](https://github.com/iurisilvio/bottle-sqlalchemy) which is +licensed under MIT license. +""" +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import functools +import inspect + +import bottle + + +class ConfigPlugin(object): + """ + A Bottle plugin to automatically pass the config object to the routes + specifying they need it. + """ + name = 'config' + api = 2 + KEYWORD = "config" + + def __init__(self, config): + """ + :param config: The config object to pass. + """ + self.config = config + + def setup(self, app): # pylint: disable=no-self-use + """ + Make sure that other installed plugins don't affect the same + keyword argument and check if metadata is available. + """ + for other in app.plugins: + if not isinstance(other, ConfigPlugin): + continue + else: + raise bottle.PluginError( + "Found another conflicting Config plugin." + ) + + def apply(self, callback, route): + """ + Method called on route invocation. Should apply some transformations to + the route prior to returing it. + + We check the presence of ``self.KEYWORD`` in the route signature and + replace the route callback by a partial invocation where we replaced + this argument by a valid config object. + """ + # Check whether the route needs a valid db session or not. + try: + callback_args = inspect.signature(route.callback).parameters + except AttributeError: + # inspect.signature does not exist on older Python + callback_args = inspect.getargspec(route.callback).args + + if self.KEYWORD not in callback_args: + # If no need for a db session, call the route callback + return callback + kwargs = {} + kwargs[self.KEYWORD] = self.config + return functools.partial(callback, **kwargs) + + +Plugin = ConfigPlugin diff --git a/flatisfy/web/dbplugin.py b/flatisfy/web/dbplugin.py index 6b5e0b8..1049b9b 100644 --- a/flatisfy/web/dbplugin.py +++ b/flatisfy/web/dbplugin.py @@ -28,13 +28,12 @@ class DatabasePlugin(object): def __init__(self, get_session): """ - :param keyword: Keyword used to inject session database in a route :param create_session: SQLAlchemy session maker created with the 'sessionmaker' function. Will create its own if undefined. """ self.get_session = get_session - def setup(self, app): # pylint: disable-no-self-use + def setup(self, app): # pylint: disable=no-self-use """ Make sure that other installed plugins don't affect the same keyword argument and check if metadata is available. @@ -67,11 +66,15 @@ class DatabasePlugin(object): # If no need for a db session, call the route callback return callback else: - # Otherwise, we get a db session and pass it to the callback - with self.get_session() as session: - kwargs = {} - kwargs[self.KEYWORD] = session - return functools.partial(callback, **kwargs) + def wrapper(*args, **kwargs): + """ + Wrap the callback in a call to get_session. + """ + with self.get_session() as session: + # Get a db session and pass it to the callback + kwargs[self.KEYWORD] = session + return callback(*args, **kwargs) + return wrapper Plugin = DatabasePlugin diff --git a/flatisfy/web/js_src/api/index.js b/flatisfy/web/js_src/api/index.js new file mode 100644 index 0000000..3b54f42 --- /dev/null +++ b/flatisfy/web/js_src/api/index.js @@ -0,0 +1,63 @@ +import moment from 'moment' + +require('es6-promise').polyfill() +require('isomorphic-fetch') + +export const getFlats = function (callback) { + fetch('/api/v1/flats') + .then(function (response) { + return response.json() + }).then(function (json) { + const flats = json.data + flats.map(flat => { + if (flat.date) { + flat.date = moment(flat.date) + } + return flat + }) + callback(flats) + }).catch(function (ex) { + console.error('Unable to parse flats: ' + ex) + }) +} + +export const getFlat = function (flatId, callback) { + fetch('/api/v1/flat/' + encodeURIComponent(flatId)) + .then(function (response) { + return response.json() + }).then(function (json) { + const flat = json.data + if (flat.date) { + flat.date = moment(flat.date) + } + callback(json.data) + }).catch(function (ex) { + console.error('Unable to parse flats: ' + ex) + }) +} + +export const updateFlatStatus = function (flatId, newStatus, callback) { + fetch( + '/api/v1/flat/' + encodeURIComponent(flatId) + '/status', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + status: newStatus + }) + } + ).then(callback) +} + +export const getTimeToPlaces = function (callback) { + fetch('/api/v1/time_to/places') + .then(function (response) { + return response.json() + }).then(function (json) { + callback(json.data) + }).catch(function (ex) { + console.error('Unable to fetch time to places: ' + ex) + }) +} diff --git a/flatisfy/web/js_src/components/app.vue b/flatisfy/web/js_src/components/app.vue new file mode 100644 index 0000000..c147d32 --- /dev/null +++ b/flatisfy/web/js_src/components/app.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/flatisfy/web/js_src/components/flatsmap.vue b/flatisfy/web/js_src/components/flatsmap.vue new file mode 100644 index 0000000..69b155d --- /dev/null +++ b/flatisfy/web/js_src/components/flatsmap.vue @@ -0,0 +1,103 @@ + + + + + + + diff --git a/flatisfy/web/js_src/components/flatstable.vue b/flatisfy/web/js_src/components/flatstable.vue new file mode 100644 index 0000000..f714900 --- /dev/null +++ b/flatisfy/web/js_src/components/flatstable.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/flatisfy/web/js_src/components/slider.vue b/flatisfy/web/js_src/components/slider.vue new file mode 100644 index 0000000..eec3c32 --- /dev/null +++ b/flatisfy/web/js_src/components/slider.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/flatisfy/web/js_src/i18n/en/index.js b/flatisfy/web/js_src/i18n/en/index.js new file mode 100644 index 0000000..5aa0603 --- /dev/null +++ b/flatisfy/web/js_src/i18n/en/index.js @@ -0,0 +1,55 @@ +export default { + common: { + 'flats': 'flat | flats', + 'loading': 'Loading…', + 'Actions': 'Actions', + 'More': 'More', + 'Remove': 'Remove', + 'Restore': 'Restore', + 'External_link': 'External link', + 'Follow': 'Follow', + 'Close': 'Close' + }, + home: { + 'new_available_flats': 'New available flats' + }, + flatListing: { + 'no_available_flats': 'No available flats.' + }, + menu: { + 'available_flats': 'Available flats', + 'followed_flats': 'Followed flats', + 'ignored_flats': 'Ignored flats', + 'user_deleted_flats': 'User deleted flats' + }, + flatsDetails: { + 'Title': 'Title', + 'Area': 'Area', + 'Rooms': 'Rooms', + 'Cost': 'Cost', + 'utilities_included': '(utilities included)', + 'utilities_excluded': '(utilities excluded)', + 'Description': 'Description', + 'Details': 'Details', + 'Metadata': 'Metadata', + 'postal_code': 'Postal code', + 'nearby_stations': 'Nearby stations', + 'Times_to': 'Times to', + 'Location': 'Location', + 'Contact': 'Contact', + 'no_phone_found': 'No phone found', + 'Original_posts': 'Original posts:', + 'Original_post': 'Original post', + 'rooms': 'room | rooms', + 'bedrooms': 'bedroom | bedrooms' + }, + status: { + 'new': 'new', + 'followed': 'followed', + 'ignored': 'ignored', + 'user_deleted': 'user deleted' + }, + slider: { + 'Fullscreen_photo': 'Fullscreen photo' + } +} diff --git a/flatisfy/web/js_src/i18n/index.js b/flatisfy/web/js_src/i18n/index.js new file mode 100644 index 0000000..9f0f33e --- /dev/null +++ b/flatisfy/web/js_src/i18n/index.js @@ -0,0 +1,52 @@ +import Vue from 'vue' +import VueI18n from 'vue-i18n' + +// Import translations +import en from './en' + +Vue.use(VueI18n) + +export function getBrowserLocales () { + let langs = [] + + if (navigator.languages) { + // Chrome does not currently set navigator.language correctly + // https://code.google.com/p/chromium/issues/detail?id=101138 + // but it does set the first element of navigator.languages correctly + langs = navigator.languages + } else if (navigator.userLanguage) { + // IE only + langs = [navigator.userLanguage] + } else { + // as of this writing the latest version of firefox + safari set this correctly + langs = [navigator.language] + } + + // Some browsers does not return uppercase for second part + const locales = langs.map(function (lang) { + const locale = lang.split('-') + return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang + }) + + return locales +} + +const messages = { + 'en': en +} + +const locales = getBrowserLocales() + +var locale = 'en' // Safe default +// Get best matching locale +for (var i = 0; i < locales.length; ++i) { + if (messages[locales[i]]) { + locale = locales[i] + break // Break at first matching locale + } +} + +export default new VueI18n({ + locale: locale, + messages +}) diff --git a/flatisfy/web/js_src/main.js b/flatisfy/web/js_src/main.js new file mode 100644 index 0000000..7c8fcd0 --- /dev/null +++ b/flatisfy/web/js_src/main.js @@ -0,0 +1,14 @@ +import Vue from 'vue' + +import i18n from './i18n' +import router from './router' +import store from './store' + +import App from './components/app.vue' + +new Vue({ + i18n, + router, + store, + render: createEle => createEle(App) +}).$mount('#app') diff --git a/flatisfy/web/js_src/router/index.js b/flatisfy/web/js_src/router/index.js new file mode 100644 index 0000000..627a459 --- /dev/null +++ b/flatisfy/web/js_src/router/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue' +import VueRouter from 'vue-router' + +import Home from '../views/home.vue' +import Status from '../views/status.vue' +import Details from '../views/details.vue' + +Vue.use(VueRouter) + +export default new VueRouter({ + routes: [ + { path: '/', component: Home, name: 'home' }, + { path: '/followed', component: Status, name: 'followed' }, + { path: '/ignored', component: Status, name: 'ignored' }, + { path: '/user_deleted', component: Status, name: 'user_deleted' }, + { path: '/flat/:id', component: Details, name: 'details' } + ] +}) diff --git a/flatisfy/web/js_src/store/actions.js b/flatisfy/web/js_src/store/actions.js new file mode 100644 index 0000000..cb2ed09 --- /dev/null +++ b/flatisfy/web/js_src/store/actions.js @@ -0,0 +1,26 @@ +import * as api from '../api' +import * as types from './mutations-types' + +export default { + getAllFlats ({ commit }) { + api.getFlats(flats => { + commit(types.REPLACE_FLATS, { flats }) + }) + }, + getFlat ({ commit }, { flatId }) { + api.getFlat(flatId, flat => { + const flats = [flat] + commit(types.MERGE_FLATS, { flats }) + }) + }, + getAllTimeToPlaces ({ commit }) { + api.getTimeToPlaces(timeToPlaces => { + commit(types.RECEIVE_TIME_TO_PLACES, { timeToPlaces }) + }) + }, + updateFlatStatus ({ commit }, { flatId, newStatus }) { + api.updateFlatStatus(flatId, newStatus, response => { + commit(types.UPDATE_FLAT_STATUS, { flatId, newStatus }) + }) + } +} diff --git a/flatisfy/web/js_src/store/getters.js b/flatisfy/web/js_src/store/getters.js new file mode 100644 index 0000000..914bd38 --- /dev/null +++ b/flatisfy/web/js_src/store/getters.js @@ -0,0 +1,56 @@ +import { findFlatGPS } from '../tools' + +export default { + allFlats: state => state.flats, + + flat: (state, getters) => id => state.flats.find(flat => flat.id === id), + + postalCodesFlatsBuckets: (state, getters) => filter => { + const postalCodeBuckets = {} + + state.flats.forEach(flat => { + if (filter && filter(flat)) { + const postalCode = flat.flatisfy_postal_code.postal_code + if (!postalCodeBuckets[postalCode]) { + postalCodeBuckets[postalCode] = { + 'name': flat.flatisfy_postal_code.name, + 'flats': [] + } + } + postalCodeBuckets[postalCode].flats.push(flat) + } + }) + + return postalCodeBuckets + }, + + flatsMarkers: (state, getters) => (router, filter) => { + const markers = [] + state.flats.forEach(flat => { + if (filter && filter(flat)) { + const gps = findFlatGPS(flat) + + if (gps) { + const previousMarkerIndex = markers.findIndex( + marker => marker.gps[0] === gps[0] && marker.gps[1] === gps[1] + ) + + const href = router.resolve({ name: 'details', params: { id: flat.id }}).href + if (previousMarkerIndex !== -1) { + markers[previousMarkerIndex].content += '
' + flat.title + '' + } else { + markers.push({ + 'title': '', + 'content': '' + flat.title + '', + 'gps': gps + }) + } + } + } + }) + + return markers + }, + + allTimeToPlaces: state => state.timeToPlaces +} diff --git a/flatisfy/web/js_src/store/index.js b/flatisfy/web/js_src/store/index.js new file mode 100644 index 0000000..7220393 --- /dev/null +++ b/flatisfy/web/js_src/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +import actions from './actions' +import getters from './getters' +import { state, mutations } from './mutations' +// import products from './modules/products' + +Vue.use(Vuex) + +export default new Vuex.Store({ + state, + actions, + getters, + mutations +}) diff --git a/flatisfy/web/js_src/store/mutations-types.js b/flatisfy/web/js_src/store/mutations-types.js new file mode 100644 index 0000000..c2138b5 --- /dev/null +++ b/flatisfy/web/js_src/store/mutations-types.js @@ -0,0 +1,4 @@ +export const REPLACE_FLATS = 'REPLACE_FLATS' +export const MERGE_FLATS = 'MERGE_FLATS' +export const UPDATE_FLAT_STATUS = 'UPDATE_FLAT_STATUS' +export const RECEIVE_TIME_TO_PLACES = 'RECEIVE_TIME_TO_PLACES' diff --git a/flatisfy/web/js_src/store/mutations.js b/flatisfy/web/js_src/store/mutations.js new file mode 100644 index 0000000..4c7604b --- /dev/null +++ b/flatisfy/web/js_src/store/mutations.js @@ -0,0 +1,34 @@ +import Vue from 'vue' + +import * as types from './mutations-types' + +export const state = { + flats: [], + timeToPlaces: [] +} + +export const mutations = { + [types.REPLACE_FLATS] (state, { flats }) { + state.flats = flats + }, + [types.MERGE_FLATS] (state, { flats }) { + flats.forEach(flat => { + const flatIndex = state.flats.findIndex(storedFlat => storedFlat.id === flat.id) + + if (flatIndex > -1) { + Vue.set(state.flats, flatIndex, flat) + } else { + state.flats.push(flat) + } + }) + }, + [types.UPDATE_FLAT_STATUS] (state, { flatId, newStatus }) { + const index = state.flats.findIndex(flat => flat.id === flatId) + if (index > -1) { + Vue.set(state.flats[index], 'status', newStatus) + } + }, + [types.RECEIVE_TIME_TO_PLACES] (state, { timeToPlaces }) { + state.timeToPlaces = timeToPlaces + } +} diff --git a/flatisfy/web/js_src/tools/index.js b/flatisfy/web/js_src/tools/index.js new file mode 100644 index 0000000..1b041c4 --- /dev/null +++ b/flatisfy/web/js_src/tools/index.js @@ -0,0 +1,21 @@ +export function findFlatGPS (flat) { + let gps + + // Try to push a marker based on stations + if (flat.flatisfy_stations && flat.flatisfy_stations.length > 0) { + gps = [0.0, 0.0] + flat.flatisfy_stations.forEach(station => { + gps = [gps[0] + station.gps[0], gps[1] + station.gps[1]] + }) + gps = [gps[0] / flat.flatisfy_stations.length, gps[1] / flat.flatisfy_stations.length] + } else { + // Else, push a marker based on postal code + gps = flat.flatisfy_postal_code.gps + } + + return gps +} + +export function capitalize (string) { + return string.charAt(0).toUpperCase() + string.slice(1) +} diff --git a/flatisfy/web/js_src/views/details.vue b/flatisfy/web/js_src/views/details.vue new file mode 100644 index 0000000..d623e9a --- /dev/null +++ b/flatisfy/web/js_src/views/details.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/flatisfy/web/js_src/views/home.vue b/flatisfy/web/js_src/views/home.vue new file mode 100644 index 0000000..d265100 --- /dev/null +++ b/flatisfy/web/js_src/views/home.vue @@ -0,0 +1,52 @@ + + + diff --git a/flatisfy/web/js_src/views/status.vue b/flatisfy/web/js_src/views/status.vue new file mode 100644 index 0000000..03841e5 --- /dev/null +++ b/flatisfy/web/js_src/views/status.vue @@ -0,0 +1,41 @@ + + + diff --git a/flatisfy/web/routes/api.py b/flatisfy/web/routes/api.py index 8306740..a61c046 100644 --- a/flatisfy/web/routes/api.py +++ b/flatisfy/web/routes/api.py @@ -6,6 +6,11 @@ from __future__ import ( absolute_import, division, print_function, unicode_literals ) +import json + +import bottle + +import flatisfy.data from flatisfy.models import flat as flat_model @@ -20,28 +25,136 @@ def index_v1(): } -def flats_v1(db): +def flats_v1(config, db): """ API v1 flats route: GET /api/v1/flats + + :return: The available flats objects in a JSON ``data`` dict. """ + postal_codes = flatisfy.data.load_data("postal_codes", config) + flats = [ flat.json_api_repr() for flat in db.query(flat_model.Flat).all() ] + + for flat in flats: + if flat["flatisfy_postal_code"]: + postal_code_data = postal_codes[flat["flatisfy_postal_code"]] + flat["flatisfy_postal_code"] = { + "postal_code": flat["flatisfy_postal_code"], + "name": postal_code_data["nom"], + "gps": postal_code_data["gps"] + } + else: + flat["flatisfy_postal_code"] = {} + return { "data": flats } -def flat_v1(flat_id, db): +def flats_by_status_v1(status, db): + """ + API v1 flats route with a specific status: + + GET /api/v1/flats/status/:status + + :return: The matching flats objects in a JSON ``data`` dict. + """ + try: + flats = [ + flat.json_api_repr() + for flat in ( + db.query(flat_model.Flat) + .filter_by(status=getattr(flat_model.FlatStatus, status)) + .all() + ) + ] + except AttributeError: + return bottle.HTTPError(400, "Invalid status provided.") + + return { + "data": flats + } + + +def flat_v1(flat_id, config, db): """ API v1 flat route: GET /api/v1/flat/:flat_id + + :return: The flat object in a JSON ``data`` dict. + """ + postal_codes = flatisfy.data.load_data("postal_codes", config) + + flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() + + if not flat: + return bottle.HTTPError(404, "No flat with id {}.".format(flat_id)) + + flat = flat.json_api_repr() + + if flat["flatisfy_postal_code"]: + postal_code_data = postal_codes[flat["flatisfy_postal_code"]] + flat["flatisfy_postal_code"] = { + "postal_code": flat["flatisfy_postal_code"], + "name": postal_code_data["nom"], + "gps": postal_code_data["gps"] + } + else: + flat["flatisfy_postal_code"] = {} + + return { + "data": flat + } + + +def update_flat_status_v1(flat_id, db): + """ + API v1 route to update flat status: + + POST /api/v1/flat/:flat_id/status + Data: { + "status": "NEW_STATUS" + } + + :return: The new flat object in a JSON ``data`` dict. """ flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() + if not flat: + return bottle.HTTPError(404, "No flat with id {}.".format(flat_id)) + + try: + flat.status = getattr( + flat_model.FlatStatus, json.load(bottle.request.body)["status"] + ) + except (AttributeError, ValueError, KeyError): + return bottle.HTTPError(400, "Invalid status provided.") + + json_flat = flat.json_api_repr() + return { - "data": flat.json_api_repr() + "data": json_flat + } + + +def time_to_places_v1(config): + """ + API v1 route to fetch the details of the places to compute time to. + + GET /api/v1/time_to/places + + :return: The JSON dump of the places to compute time to (dict of places + names mapped to GPS coordinates). + """ + places = { + k: v["gps"] + for k, v in config["constraints"]["time_to"].items() + } + return { + "data": places } diff --git a/flatisfy/web/static/index.html b/flatisfy/web/static/index.html index 6c6f44c..00366cf 100644 --- a/flatisfy/web/static/index.html +++ b/flatisfy/web/static/index.html @@ -2,29 +2,13 @@ + Flatisfy - + + -
-

Flatisfy

- - - - - - - - - -
TitreLien
-
- +
+ diff --git a/flatisfy/web/static/js/9e9c77db241e8a58da99bf28694c907d.png b/flatisfy/web/static/js/9e9c77db241e8a58da99bf28694c907d.png new file mode 100644 index 0000000000000000000000000000000000000000..33cf95504706aa9d06ad40dbcd2fa168cbd43d13 GIT binary patch literal 535 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 C9D%U_ literal 0 HcmV?d00001 diff --git a/flatisfy/web/static/js/d3a5d64a8534322988a4bed1b7dbc8b0.png b/flatisfy/web/static/js/d3a5d64a8534322988a4bed1b7dbc8b0.png new file mode 100644 index 0000000000000000000000000000000000000000..1116503f6a572bec04496f164270dbbdffaefb84 GIT binary patch literal 1469 zcmV;u1w#6XP)$jsrRkUQsMcbX>z|j`P_4F?%Q_@f8Trt_r4(hxNiEFbHd*`e3wA~&9LoB z|JTX7kq}paKe_vc2M*ug64EAp8fjKield;^5n(28CRtDv6+dL~Fp+@_%zJ24`YVVq zBpE?@#aJOC0|_EPCT%8Af=BS!Pl8hiNCud49YlR>}1Tb-#*dPo+ z)$qjd<2BSi`Tw*-+e7qSQIZB2F9|{pVj$PjAteZrRFvox2~1WdF&r3yIy5pE{RB@l zFz=xq`k=pIC2L3tDv2DDqf#RRd?YC%r9h(yK?V}y#2Ne;Gy~%>VVE>brD8req55dj z?`V{SpsL6rIix0C3`wz4Xz)=K5@jHwsV3J7Ob2FQ7TONa5@{P|X@8nNrj3)41?_i<^tV759Srm^CO?9y)MiF(vXB8C1-?G z4cXS3j7SqpABu$-r~P)!vew(g&Y!umz$z!D6f{YWcMIh87s;8WY?KYzKA%uM*i&Y{OPyGq6Fs zND?_l42fYWNsXzKmMY_qTXxY&%XNuCjBL%1X}m$FZ3eW=ptqRhe&8VRI&f&=b<3}T z1K5LI+Cvh8kVC&qEUWpL7FA!ySwbVa=-RE?Ip857O-z|6KBeO>gNjo2nB;NbByi?% z3MX(JI8xFMkh^EZkQ$^&o$0EiOsQg9XZcj)^09RVjFdDn{Yp#)^G&{$dE&gm1P+<# zo50(^?-~5Y|GnjX=g5kJ4kbN^VM$SQ){wHO>Pp)zora`@9f+DD%g0H|4aC%l=nOl; z3IpF~a_0zmANa#?!RzmMPm~i=bVOpJl${JAOZ3}STl1s{Vdu#uM5J6@UE$=qt=bne zOO#ic!0RXl373Hn8eaQT1d$*~&9M@*tCr6TzJNuubXrJ>+i}fo$8t_#>G&xTG05@7 zm>N@zTeV8eLY5dN|5xB+;BWZAYf;QQ5_3+{jxv(6ri46KLZ(HCq{JNcNr?!t7gbh< z6?4zvZ`V?Mn~s<_O!!hufsZON?^RMxV_hL7+p);1TG9)2)Y)X*oMdfrE&4wW1rZ^n zh!Hs)@aVXzcBLN0cav}WloVXV)W9Xni`qg;ST?c1svT!ZEt$4mGA1Exza}AW9T6Nc z{!;Q%!?!W0&M|Y;nRT@*ovGq`0U|^aa-b5jo*|^;FBv(Hq%Ilw&tHBqg51N%A+JVi zrkTVtliOuUoFL!~ObubVu+t#7r_Lgi=5S`W-GTINSA^sclX#_x7zR_~TdOmHw%VOG zMGmK4Io0k^6jY~%^!?vr?AuzkMzhjgZiQ8>@H*G=ssxgX6?CU+ywRihMBIh2`n_z%eLT1;Q&-3)SwdsU@p zOzw5n)gm%XGRpSW-lik(1F^doLuy10r(wDOt*~=EVe(*Z=-Fl+8U1cCHGf}TjQ{(| z$|>1rNs5Y1hb^X72z)JK?4eJd957LN%xWK", + "license": "MIT", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://Phyks@git.phyks.me/Phyks/flatisfy.git" + }, + "homepage": "https://git.phyks.me/Phyks/flatisfy", + "scripts": { + "build": "webpack --colors --progress", + "watch": "webpack --colors --progress --watch", + "lint": "eslint --ext .js,.vue ./flatisfy/web/js_src/**" + }, + "dependencies": { + "es6-promise": "^4.1.0", + "imagesloaded": "^4.1.1", + "isomorphic-fetch": "^2.2.1", + "isotope-layout": "^3.0.3", + "leaflet.icon.glyph": "^0.2.0", + "masonry": "0.0.2", + "moment": "^2.18.1", + "vue": "^2.2.6", + "vue-i18n": "^6.1.1", + "vue-images-loaded": "^1.1.2", + "vue-router": "^2.4.0", + "vue2-leaflet": "0.0.44", + "vueisotope": "^3.0.0-rc", + "vuex": "^2.3.0" + }, + "devDependencies": { + "babel-core": "^6.24.1", + "babel-loader": "^6.4.1", + "babel-plugin-transform-runtime": "^6.23.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-stage-0": "^6.24.1", + "css-loader": "^0.28.0", + "eslint": "^3.19.0", + "eslint-config-vue": "^2.0.2", + "eslint-plugin-vue": "^2.0.1", + "file-loader": "^0.11.1", + "image-webpack-loader": "^3.3.0", + "style-loader": "^0.16.1", + "vue-html-loader": "^1.2.4", + "vue-loader": "^11.3.4", + "vue-template-compiler": "^2.2.6", + "webpack": "^2.3.3" + } +} diff --git a/requirements.txt b/requirements.txt index c74311d..8f32c51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ appdirs +arrow bottle bottle-sqlalchemy +canister enum34 future request diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..054e6b2 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,55 @@ +module.exports = { + entry: './flatisfy/web/js_src/main.js', + output: { + path: __dirname + '/flatisfy/web/static/js/', + filename: 'bundle.js' + }, + module: { + loaders: [ + { + test: /\.js$/, + exclude: /(node_modules|bower_components)/, + use: { + loader: 'babel-loader' + } + }, + { + test: /\.vue$/, + loader: 'vue-loader', + options: { + loaders: { + js: 'babel-loader' + } + } + }, + { + test: /\.css$/, + loader: 'style-loader!css-loader' + }, + { + test: /\.(jpe?g|png|gif|svg)$/i, + loaders: [ + 'file-loader?hash=sha512&digest=hex&name=[hash].[ext]', + { + loader: 'image-webpack-loader', + query: { + bypassOnDebug: true, + 'optipng': { + optimizationLevel: 7 + }, + 'gifsicle': { + interlaced: false + } + } + } + ] + } + ] + }, + resolve: { + alias: { + 'masonry': 'masonry-layout', + 'isotope': 'isotope-layout' + } + } +} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..fa9d6ac --- /dev/null +++ b/wsgi.py @@ -0,0 +1,33 @@ +# coding: utf-8 +""" +Expose a WSGI-compatible application to serve with a webserver. +""" +from __future__ import absolute_import, print_function, unicode_literals + +import logging +import os +import sys + +import flatisfy.config +from flatisfy.web import app as web_app + + +class Args(): + config = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "config/config.json" + ) + + +LOGGER = logging.getLogger("flatisfy") + + +CONFIG = flatisfy.config.load_config(Args()) +if CONFIG is None: + LOGGER.error("Invalid configuration. Exiting. " + "Run init-config before if this is the first time " + "you run Flatisfy.") + sys.exit(1) + + +application = app = web_app.get_app(CONFIG)