Browse Source

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.
Phyks (Lucas Verney) 4 years ago
parent
commit
a57d9ce8e3
49 changed files with 1988 additions and 120 deletions
  1. 4
    0
      .babelrc
  2. 10
    0
      .eslintrc
  3. 3
    1
      .gitignore
  4. 46
    0
      CONTRIBUTING.md
  5. 5
    0
      README.md
  6. 10
    0
      flatisfy/__main__.py
  7. 34
    8
      flatisfy/cmds.py
  8. 30
    11
      flatisfy/config.py
  9. 2
    1
      flatisfy/data.py
  10. 3
    1
      flatisfy/database/__init__.py
  11. 1
    1
      flatisfy/database/types.py
  12. 165
    25
      flatisfy/fetch.py
  13. 8
    7
      flatisfy/filters/__init__.py
  14. 26
    1
      flatisfy/filters/duplicates.py
  15. 16
    4
      flatisfy/filters/metadata.py
  16. 53
    11
      flatisfy/models/flat.py
  17. 52
    18
      flatisfy/tools.py
  18. 35
    0
      flatisfy/web/app.py
  19. 72
    0
      flatisfy/web/configplugin.py
  20. 10
    7
      flatisfy/web/dbplugin.py
  21. 63
    0
      flatisfy/web/js_src/api/index.js
  22. 76
    0
      flatisfy/web/js_src/components/app.vue
  23. 103
    0
      flatisfy/web/js_src/components/flatsmap.vue
  24. 90
    0
      flatisfy/web/js_src/components/flatstable.vue
  25. 144
    0
      flatisfy/web/js_src/components/slider.vue
  26. 55
    0
      flatisfy/web/js_src/i18n/en/index.js
  27. 52
    0
      flatisfy/web/js_src/i18n/index.js
  28. 14
    0
      flatisfy/web/js_src/main.js
  29. 18
    0
      flatisfy/web/js_src/router/index.js
  30. 26
    0
      flatisfy/web/js_src/store/actions.js
  31. 56
    0
      flatisfy/web/js_src/store/getters.js
  32. 16
    0
      flatisfy/web/js_src/store/index.js
  33. 4
    0
      flatisfy/web/js_src/store/mutations-types.js
  34. 34
    0
      flatisfy/web/js_src/store/mutations.js
  35. 21
    0
      flatisfy/web/js_src/tools/index.js
  36. 275
    0
      flatisfy/web/js_src/views/details.vue
  37. 52
    0
      flatisfy/web/js_src/views/home.vue
  38. 41
    0
      flatisfy/web/js_src/views/status.vue
  39. 116
    3
      flatisfy/web/routes/api.py
  40. 5
    21
      flatisfy/web/static/index.html
  41. BIN
      flatisfy/web/static/js/9e9c77db241e8a58da99bf28694c907d.png
  42. BIN
      flatisfy/web/static/js/a159160a02a1caf189f5008c0d150f36.png
  43. BIN
      flatisfy/web/static/js/c6477ef24ce2054017fce1a2d8800385.png
  44. BIN
      flatisfy/web/static/js/d3a5d64a8534322988a4bed1b7dbc8b0.png
  45. 1
    0
      hooks/pre-commit
  46. 51
    0
      package.json
  47. 2
    0
      requirements.txt
  48. 55
    0
      webpack.config.js
  49. 33
    0
      wsgi.py

+ 4
- 0
.babelrc View File

@@ -0,0 +1,4 @@
1
+{
2
+    "presets": ["es2015", "stage-0"],
3
+    "plugins": ["transform-runtime"]
4
+}

+ 10
- 0
.eslintrc View File

@@ -0,0 +1,10 @@
1
+{
2
+    extends: ["vue", /* your other extends */],
3
+    plugins: ["vue"],
4
+    "env": {
5
+        "browser": true
6
+    },
7
+    rules: {
8
+        'indent': ["error", 4, { 'SwitchCase': 1 }],
9
+    }
10
+}

+ 3
- 1
.gitignore View File

@@ -1,6 +1,8 @@
1 1
 build
2
-*.json
3 2
 *.pyc
4 3
 *.swp
5 4
 *.swo
6 5
 *.db
6
+config/
7
+node_modules
8
+flatisfy/web/static/js

+ 46
- 0
CONTRIBUTING.md View File

@@ -0,0 +1,46 @@
1
+## TL;DR
2
+
3
+* Branch off `master`.
4
+* One feature per commit.
5
+* In case of changes request, amend your commit.
6
+
7
+
8
+## Useful infos
9
+
10
+* There is a `hooks/pre-commit` file which can be used as a `pre-commit` git
11
+  hook to check coding style.
12
+* Python coding style is PEP8. JS coding style is enforced by `eslint`.
13
+* Some useful `npm` scripts are provided (`build` / `watch` / `lint`)
14
+
15
+
16
+## Translating the webapp
17
+
18
+If you want to translate the webapp, just create a new folder in
19
+`flatisfy/web/js_src/i18n` with the short name of your locale (typically, `en`
20
+is for english). Copy the `flatisfy/web/js_src/i18n/en/index.js` file to this
21
+new folder and translate the `messages` strings.
22
+
23
+Then, edit `flatisfy/web/js_src/i18n/index.js` file to include your new
24
+locale.
25
+
26
+
27
+## How to contribute
28
+
29
+* If you're thinking about a new feature, see if there's already an issue open
30
+  about it, or please open one otherwise. This will ensure that everybody is on
31
+  track for the feature and willing to see it in Flatisfy.
32
+* One commit per feature.
33
+* Branch off the `master ` branch.
34
+* Check the linting of your code before doing a PR.
35
+* Ideally, your merge-request should be mergeable without any merge commit, that
36
+  is, it should be a fast-forward merge. For this to happen, your code needs to
37
+  be always rebased onto `master`. Again, this is something nice to have that
38
+  I expect from recurring contributors, but not a big deal if you don't do it
39
+  otherwise.
40
+* I'll look at it and might ask for a few changes. In this case, please create
41
+  new commits. When the final result looks good, I may ask you to squash the
42
+  WIP commits into a single one, to maintain the invariant of "one feature, one
43
+  commit".
44
+
45
+
46
+Thanks!

+ 5
- 0
README.md View File

@@ -104,6 +104,11 @@ The content of this repository is licensed under an MIT license, unless
104 104
 explicitly mentionned otherwise.
105 105
 
106 106
 
107
+## Contributing
108
+
109
+See the `CONTRIBUTING.md` file for more infos.
110
+
111
+
107 112
 ## Thanks
108 113
 
109 114
 * [Weboob](http://weboob.org/)

+ 10
- 0
flatisfy/__main__.py View File

@@ -90,6 +90,10 @@ def parse_args(argv=None):
90 90
     subparsers.add_parser("import", parents=[parent_parser],
91 91
                           help="Import housing posts in database.")
92 92
 
93
+    # Purge subcommand parser
94
+    subparsers.add_parser("purge", parents=[parent_parser],
95
+                          help="Purge database.")
96
+
93 97
     # Serve subcommand parser
94 98
     parser_serve = subparsers.add_parser("serve", parents=[parent_parser],
95 99
                                          help="Serve the web app.")
@@ -103,6 +107,7 @@ def main():
103 107
     """
104 108
     Main module code.
105 109
     """
110
+    # pylint: disable=locally-disabled,too-many-branches
106 111
     # Parse arguments
107 112
     args = parse_args()
108 113
 
@@ -163,7 +168,12 @@ def main():
163 168
         )
164 169
     # Import command
165 170
     elif args.cmd == "import":
171
+        # TODO: Do not fetch details for already imported flats / use the last
172
+        # timestamp
166 173
         cmds.import_and_filter(config)
174
+    # Purge command
175
+    elif args.cmd == "purge":
176
+        cmds.purge_db(config)
167 177
     # Serve command
168 178
     elif args.cmd == "serve":
169 179
         cmds.serve(config)

+ 34
- 8
flatisfy/cmds.py View File

@@ -4,6 +4,8 @@ Main commands available for flatisfy.
4 4
 """
5 5
 from __future__ import absolute_import, print_function, unicode_literals
6 6
 
7
+import logging
8
+
7 9
 import flatisfy.filters
8 10
 from flatisfy import database
9 11
 from flatisfy.models import flat as flat_model
@@ -12,6 +14,9 @@ from flatisfy import tools
12 14
 from flatisfy.web import app as web_app
13 15
 
14 16
 
17
+LOGGER = logging.getLogger(__name__)
18
+
19
+
15 20
 def fetch_and_filter(config):
16 21
     """
17 22
     Fetch the available flats list. Then, filter it according to criteria.
@@ -34,9 +39,9 @@ def fetch_and_filter(config):
34 39
     # additional infos
35 40
     if config["passes"] > 1:
36 41
         # Load additional infos
37
-        for flat in flats_list:
38
-            details = fetch.fetch_details(flat["id"])
39
-            flat = tools.merge_dicts(flat, details)
42
+        for i, flat in enumerate(flats_list):
43
+            details = fetch.fetch_details(config, flat["id"])
44
+            flats_list[i] = tools.merge_dicts(flat, details)
40 45
 
41 46
         flats_list, extra_ignored_flats = flatisfy.filters.second_pass(
42 47
             flats_list, config
@@ -83,7 +88,7 @@ def import_and_filter(config):
83 88
     :return: ``None``.
84 89
     """
85 90
     # Fetch and filter flats list
86
-    flats_list, purged_list = fetch_and_filter(config)
91
+    flats_list, ignored_list = fetch_and_filter(config)
87 92
     # Create database connection
88 93
     get_session = database.init_db(config["database"])
89 94
 
@@ -92,12 +97,27 @@ def import_and_filter(config):
92 97
             flat = flat_model.Flat.from_dict(flat_dict)
93 98
             session.merge(flat)
94 99
 
95
-        for flat_dict in purged_list:
100
+        for flat_dict in ignored_list:
96 101
             flat = flat_model.Flat.from_dict(flat_dict)
97
-            flat.status = flat_model.FlatStatus.purged
102
+            flat.status = flat_model.FlatStatus.ignored
98 103
             session.merge(flat)
99 104
 
100 105
 
106
+def purge_db(config):
107
+    """
108
+    Purge the database.
109
+
110
+    :param config: A config dict.
111
+    :return: ``None``
112
+    """
113
+    get_session = database.init_db(config["database"])
114
+
115
+    with get_session() as session:
116
+        # Delete every flat in the db
117
+        LOGGER.info("Purge all flats from the database.")
118
+        session.query(flat_model.Flat).delete(synchronize_session=False)
119
+
120
+
101 121
 def serve(config):
102 122
     """
103 123
     Serve the web app.
@@ -106,5 +126,11 @@ def serve(config):
106 126
     :return: ``None``, long-running process.
107 127
     """
108 128
     app = web_app.get_app(config)
109
-    # TODO: Make Bottle use logging module
110
-    app.run(host=config["host"], port=config["port"])
129
+
130
+    server = config.get("webserver", None)
131
+    if not server:
132
+        # Default webserver is quiet, as Bottle is used with Canister for
133
+        # standard logging
134
+        server = web_app.QuietWSGIRefServer
135
+
136
+    app.run(host=config["host"], port=config["port"], server=server)

+ 30
- 11
flatisfy/config.py View File

@@ -21,10 +21,11 @@ from flatisfy import tools
21 21
 
22 22
 # Default configuration
23 23
 DEFAULT_CONFIG = {
24
-    # Flatboob queries to fetch
25
-    "queries": [],
26 24
     # Constraints to match
27 25
     "constraints": {
26
+        "type": None,  # RENT, SALE, SHARING
27
+        "house_types": [],  # List of house types, must be in APART, HOUSE,
28
+                            # PARKING, LAND, OTHER or UNKNOWN
28 29
         "postal_codes": [],  # List of postal codes
29 30
         "area": (None, None),  # (min, max) in m^2
30 31
         "cost": (None, None),  # (min, max) in currency unit
@@ -42,12 +43,18 @@ DEFAULT_CONFIG = {
42 43
     "max_entries": None,
43 44
     # Directory in wich data will be put. ``None`` is XDG default location.
44 45
     "data_directory": None,
46
+    # Path to the modules directory containing all Weboob modules. ``None`` if
47
+    # ``weboob_modules`` package is pip-installed, and you want to use
48
+    # ``pkgresource`` to automatically find it.
49
+    "modules_path": None,
45 50
     # SQLAlchemy URI to the database to use
46 51
     "database": None,
47 52
     # Web app port
48 53
     "port": 8080,
49 54
     # Web app host to listen on
50
-    "host": "127.0.0.1"
55
+    "host": "127.0.0.1",
56
+    # Web server to use to serve the webapp (see Bottle deployment doc)
57
+    "webserver": None
51 58
 }
52 59
 
53 60
 LOGGER = logging.getLogger(__name__)
@@ -68,7 +75,7 @@ def validate_config(config):
68 75
         assert all(
69 76
             x is None or
70 77
             (
71
-                (isinstance(x, int) or isinstance(x, float)) and
78
+                isinstance(x, (float, int)) and
72 79
                 x >= 0
73 80
             )
74 81
             for x in bounds
@@ -81,9 +88,19 @@ def validate_config(config):
81 88
         # Then, we disable line-too-long pylint check and E501 flake8 checks
82 89
         # and use long lines whenever needed, in order to have the full assert
83 90
         # message in the log output.
84
-        # pylint: disable=line-too-long
91
+        # pylint: disable=locally-disabled,line-too-long
92
+        assert "type" in config["constraints"]
93
+        assert config["constraints"]["type"].upper() in ["RENT",
94
+                                                         "SALE", "SHARING"]
95
+
96
+        assert "house_types" in config["constraints"]
97
+        assert config["constraints"]["house_types"]
98
+        for house_type in config["constraints"]["house_types"]:
99
+            assert house_type.upper() in ["APART", "HOUSE", "PARKING", "LAND",
100
+                                          "OTHER", "UNKNOWN"]
101
+
85 102
         assert "postal_codes" in config["constraints"]
86
-        assert len(config["constraints"]["postal_codes"]) > 0
103
+        assert config["constraints"]["postal_codes"]
87 104
 
88 105
         assert "area" in config["constraints"]
89 106
         _check_constraints_bounds(config["constraints"]["area"])
@@ -111,11 +128,13 @@ def validate_config(config):
111 128
         assert config["max_entries"] is None or (isinstance(config["max_entries"], int) and config["max_entries"] > 0)  # noqa: E501
112 129
 
113 130
         assert config["data_directory"] is None or isinstance(config["data_directory"], str)  # noqa: E501
131
+        assert config["modules_path"] is None or isinstance(config["modules_path"], str)  # noqa: E501
114 132
 
115 133
         assert config["database"] is None or isinstance(config["database"], str)  # noqa: E501
116 134
 
117 135
         assert isinstance(config["port"], int)
118 136
         assert isinstance(config["host"], str)
137
+        assert config["webserver"] is None or isinstance(config["webserver"], str)  # noqa: E501
119 138
 
120 139
         return True
121 140
     except (AssertionError, KeyError):
@@ -140,10 +159,11 @@ def load_config(args=None):
140 159
         try:
141 160
             with open(args.config, "r") as fh:
142 161
                 config_data.update(json.load(fh))
143
-        except (IOError, ValueError):
162
+        except (IOError, ValueError) as exc:
144 163
             LOGGER.error(
145 164
                 "Unable to load configuration from file, "
146
-                "using default configuration."
165
+                "using default configuration: %s.",
166
+                exc
147 167
             )
148 168
 
149 169
     # Overload config with arguments
@@ -188,9 +208,8 @@ def load_config(args=None):
188 208
     if config_validation is True:
189 209
         LOGGER.info("Config has been fully initialized.")
190 210
         return config_data
191
-    else:
192
-        LOGGER.error("Error in configuration: %s.", config_validation)
193
-        return None
211
+    LOGGER.error("Error in configuration: %s.", config_validation)
212
+    return None
194 213
 
195 214
 
196 215
 def init_config(output=None):

+ 2
- 1
flatisfy/data.py View File

@@ -9,6 +9,7 @@ import collections
9 9
 import json
10 10
 import logging
11 11
 import os
12
+import shutil
12 13
 
13 14
 import flatisfy.exceptions
14 15
 
@@ -157,7 +158,7 @@ def load_data(data_type, config):
157 158
         LOGGER.error("Invalid JSON data file: %s.", datafile_path)
158 159
         return None
159 160
 
160
-    if len(data) == 0:
161
+    if not data:
161 162
         LOGGER.warning("Loading empty data for %s.", data_type)
162 163
 
163 164
     return data

+ 3
- 1
flatisfy/database/__init__.py View File

@@ -41,16 +41,18 @@ def init_db(database_uri=None):
41 41
 
42 42
     engine = create_engine(database_uri)
43 43
     BASE.metadata.create_all(engine, checkfirst=True)
44
-    Session = sessionmaker(bind=engine)  # pylint: disable=invalid-name
44
+    Session = sessionmaker(bind=engine)  # pylint: disable=locally-disabled,invalid-name
45 45
 
46 46
     @contextmanager
47 47
     def get_session():
48
+        # pylint: disable=locally-disabled,line-too-long
48 49
         """
49 50
         Provide a transactional scope around a series of operations.
50 51
 
51 52
         From [1].
52 53
         [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.
53 54
         """
55
+        # pylint: enable=line-too-long,locally-disabled
54 56
         session = Session()
55 57
         try:
56 58
             yield session

+ 1
- 1
flatisfy/database/types.py View File

@@ -46,5 +46,5 @@ class StringyJSON(types.TypeDecorator):
46 46
 
47 47
 # TypeEngine.with_variant says "use StringyJSON instead when
48 48
 # connecting to 'sqlite'"
49
-# pylint: disable=invalid-name
49
+# pylint: disable=locally-disabled,invalid-name
50 50
 MagicJSON = types.JSON().with_variant(StringyJSON, 'sqlite')

+ 165
- 25
flatisfy/fetch.py View File

@@ -4,14 +4,159 @@ This module contains all the code related to fetching and loading flats lists.
4 4
 """
5 5
 from __future__ import absolute_import, print_function, unicode_literals
6 6
 
7
+import itertools
7 8
 import json
8 9
 import logging
9
-import subprocess
10 10
 
11
+from flatisfy import data
12
+from flatisfy import tools
11 13
 
12 14
 LOGGER = logging.getLogger(__name__)
13 15
 
14 16
 
17
+try:
18
+    from weboob.capabilities.housing import Query
19
+    from weboob.core.ouiboube import WebNip
20
+    from weboob.tools.json import WeboobEncoder
21
+except ImportError:
22
+    LOGGER.error("Weboob is not available on your system. Make sure you "
23
+                 "installed it.")
24
+    raise
25
+
26
+
27
+class WeboobProxy(object):
28
+    """
29
+    Wrapper around Weboob ``WebNip`` class, to fetch housing posts without
30
+    having to spawn a subprocess.
31
+    """
32
+    @staticmethod
33
+    def version():
34
+        """
35
+        Get Weboob version.
36
+
37
+        :return: The installed Weboob version.
38
+        """
39
+        return WebNip.VERSION
40
+
41
+    def __init__(self, config):
42
+        """
43
+        Create a Weboob handle and try to load the modules.
44
+
45
+        :param config: A config dict.
46
+        """
47
+        # Create base WebNip object
48
+        self.webnip = WebNip(modules_path=config["modules_path"])
49
+
50
+        # Create backends
51
+        self.backends = [
52
+            self.webnip.load_backend(
53
+                module,
54
+                module,
55
+                params={}
56
+            )
57
+            for module in ["seloger", "pap", "leboncoin", "logicimmo",
58
+                           "explorimmo", "entreparticuliers"]
59
+        ]
60
+
61
+    def __enter__(self):
62
+        return self
63
+
64
+    def __exit__(self, *args):
65
+        self.webnip.deinit()
66
+
67
+    def build_queries(self, constraints_dict):
68
+        """
69
+        Build Weboob ``weboob.capabilities.housing.Query`` objects from the
70
+        constraints defined in the configuration. Each query has at most 3
71
+        postal codes, to comply with housing websites limitations.
72
+
73
+        :param constraints_dict: A dictionary of constraints, as defined in the
74
+        config.
75
+        :return: A list of Weboob ``weboob.capabilities.housing.Query``
76
+        objects. Returns ``None`` if an error occurred.
77
+        """
78
+        queries = []
79
+        for postal_codes in tools.batch(constraints_dict["postal_codes"], 3):
80
+            query = Query()
81
+            query.cities = []
82
+            for postal_code in postal_codes:
83
+                try:
84
+                    for city in self.webnip.do("search_city", postal_code):
85
+                        query.cities.append(city)
86
+                except IndexError:
87
+                    LOGGER.error(
88
+                        "Postal code %s could not be matched with a city.",
89
+                        postal_code
90
+                    )
91
+                    return None
92
+
93
+            try:
94
+                query.house_types = [
95
+                    getattr(
96
+                        Query.HOUSE_TYPES,
97
+                        house_type.upper()
98
+                    )
99
+                    for house_type in constraints_dict["house_types"]
100
+                ]
101
+            except AttributeError:
102
+                LOGGER.error("Invalid house types constraint.")
103
+                return None
104
+
105
+            try:
106
+                query.type = getattr(
107
+                    Query,
108
+                    "TYPE_{}".format(constraints_dict["type"].upper())
109
+                )
110
+            except AttributeError:
111
+                LOGGER.error("Invalid post type constraint.")
112
+                return None
113
+
114
+            query.area_min = constraints_dict["area"][0]
115
+            query.area_max = constraints_dict["area"][1]
116
+            query.cost_min = constraints_dict["cost"][0]
117
+            query.cost_max = constraints_dict["cost"][1]
118
+            query.nb_rooms = constraints_dict["rooms"][0]
119
+
120
+            queries.append(query)
121
+
122
+        return queries
123
+
124
+    def query(self, query, max_entries=None):
125
+        """
126
+        Fetch the housings posts matching a given Weboob query.
127
+
128
+        :param query: A Weboob `weboob.capabilities.housing.Query`` object.
129
+        :param max_entries: Maximum number of entries to fetch.
130
+        :return: The matching housing posts, dumped as a list of JSON objects.
131
+        """
132
+        housings = []
133
+        # TODO: Handle max_entries better
134
+        for housing in itertools.islice(
135
+                self.webnip.do('search_housings', query),
136
+                max_entries
137
+        ):
138
+            housings.append(json.dumps(housing, cls=WeboobEncoder))
139
+        return housings
140
+
141
+    def info(self, full_flat_id):
142
+        """
143
+        Get information (details) about an housing post.
144
+
145
+        :param full_flat_id: A Weboob housing post id, in complete form
146
+        (ID@BACKEND)
147
+        :return: The details in JSON.
148
+        """
149
+        flat_id, backend_name = full_flat_id.rsplit("@", 1)
150
+        backend = next(
151
+            backend
152
+            for backend in self.backends
153
+            if backend.name == backend_name
154
+        )
155
+        housing = backend.get_housing(flat_id)
156
+        housing.id = full_flat_id  # Otherwise, we miss the @backend afterwards
157
+        return json.dumps(housing, cls=WeboobEncoder)
158
+
159
+
15 160
 def fetch_flats_list(config):
16 161
     """
17 162
     Fetch the available flats using the Flatboob / Weboob config.
@@ -20,40 +165,35 @@ def fetch_flats_list(config):
20 165
     :return: A list of all available flats.
21 166
     """
22 167
     flats_list = []
23
-    for query in config["queries"]:
24
-        max_entries = config["max_entries"]
25
-        if max_entries is None:
26
-            max_entries = 0
27
-
28
-        LOGGER.info("Loading flats from query %s.", query)
29
-        flatboob_output = subprocess.check_output(
30
-            ["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob",
31
-             "-n", str(max_entries), "-f", "json", "load", query]
32
-        )
33
-        query_flats_list = json.loads(flatboob_output)
34
-        LOGGER.info("Fetched %d flats.", len(query_flats_list))
35
-        flats_list.extend(query_flats_list)
36
-    LOGGER.info("Fetched a total of %d flats.", len(flats_list))
168
+
169
+    with WeboobProxy(config) as weboob_proxy:
170
+        LOGGER.info("Loading flats...")
171
+        queries = weboob_proxy.build_queries(config["constraints"])
172
+        housing_posts = []
173
+        for query in queries:
174
+            housing_posts.extend(
175
+                weboob_proxy.query(query, config["max_entries"])
176
+            )
177
+        LOGGER.info("Fetched %d flats.", len(housing_posts))
178
+
179
+    flats_list = [json.loads(flat) for flat in housing_posts]
37 180
     return flats_list
38 181
 
39 182
 
40
-def fetch_details(flat_id):
183
+def fetch_details(config, flat_id):
41 184
     """
42 185
     Fetch the additional details for a flat using Flatboob / Weboob.
43 186
 
187
+    :param config: A config dict.
44 188
     :param flat_id: ID of the flat to fetch details for.
45 189
     :return: A flat dict with all the available data.
46 190
     """
47
-    LOGGER.info("Loading additional details for flat %s.", flat_id)
48
-    flatboob_output = subprocess.check_output(
49
-        ["../weboob/tools/local_run.sh", "../weboob/scripts/flatboob",
50
-         "-f", "json", "info", flat_id]
51
-    )
52
-    flat_details = json.loads(flatboob_output)
53
-    LOGGER.info("Fetched details for flat %s.", flat_id)
191
+    with WeboobProxy(config) as weboob_proxy:
192
+        LOGGER.info("Loading additional details for flat %s.", flat_id)
193
+        weboob_output = weboob_proxy.info(flat_id)
54 194
 
55
-    if flat_details:
56
-        flat_details = flat_details[0]
195
+    flat_details = json.loads(weboob_output)
196
+    LOGGER.info("Fetched details for flat %s.", flat_id)
57 197
 
58 198
     return flat_details
59 199
 

+ 8
- 7
flatisfy/filters/__init__.py View File

@@ -89,9 +89,10 @@ def first_pass(flats_list, config):
89 89
 
90 90
     :param flats_list: A list of flats dict to filter.
91 91
     :param config: A config dict.
92
-    :return: A tuple of processed flats and purged flats.
92
+    :return: A tuple of processed flats and ignored flats.
93 93
     """
94 94
     LOGGER.info("Running first filtering pass.")
95
+
95 96
     # Handle duplicates based on ids
96 97
     # Just remove them (no merge) as they should be the exact same object.
97 98
     flats_list = duplicates.detect(
@@ -105,16 +106,16 @@ def first_pass(flats_list, config):
105 106
         flats_list, key="url", merge=True
106 107
     )
107 108
 
108
-    # Add the flatisfy metadata entry
109
+    # Add the flatisfy metadata entry and prepare the flat objects
109 110
     flats_list = metadata.init(flats_list)
110 111
     # Guess the postal codes
111 112
     flats_list = metadata.guess_postal_code(flats_list, config)
112 113
     # Try to match with stations
113 114
     flats_list = metadata.guess_stations(flats_list, config)
114 115
     # Remove returned housing posts that do not match criteria
115
-    flats_list, purged_list = refine_with_housing_criteria(flats_list, config)
116
+    flats_list, ignored_list = refine_with_housing_criteria(flats_list, config)
116 117
 
117
-    return (flats_list, purged_list)
118
+    return (flats_list, ignored_list)
118 119
 
119 120
 
120 121
 def second_pass(flats_list, config):
@@ -130,7 +131,7 @@ def second_pass(flats_list, config):
130 131
 
131 132
     :param flats_list: A list of flats dict to filter.
132 133
     :param config: A config dict.
133
-    :return: A tuple of processed flats and purged flats.
134
+    :return: A tuple of processed flats and ignored flats.
134 135
     """
135 136
     LOGGER.info("Running second filtering pass.")
136 137
     # Assumed to run after first pass, so there should be no obvious duplicates
@@ -148,6 +149,6 @@ def second_pass(flats_list, config):
148 149
     flats_list = metadata.compute_travel_times(flats_list, config)
149 150
 
150 151
     # Remove returned housing posts that do not match criteria
151
-    flats_list, purged_list = refine_with_housing_criteria(flats_list, config)
152
+    flats_list, ignored_list = refine_with_housing_criteria(flats_list, config)
152 153
 
153
-    return (flats_list, purged_list)
154
+    return (flats_list, ignored_list)

+ 26
- 1
flatisfy/filters/duplicates.py View File

@@ -5,9 +5,23 @@ Filtering functions to detect and merge duplicates.
5 5
 from __future__ import absolute_import, print_function, unicode_literals
6 6
 
7 7
 import collections
8
+import logging
8 9
 
9 10
 from flatisfy import tools
10 11
 
12
+LOGGER = logging.getLogger(__name__)
13
+
14
+# Some backends give more infos than others. Here is the precedence we want to
15
+# use.
16
+BACKENDS_PRECEDENCE = [
17
+    "seloger",
18
+    "pap",
19
+    "leboncoin",
20
+    "explorimmo",
21
+    "logicimmo",
22
+    "entreparticuliers"
23
+]
24
+
11 25
 
12 26
 def detect(flats_list, key="id", merge=True):
13 27
     """
@@ -27,7 +41,6 @@ def detect(flats_list, key="id", merge=True):
27 41
 
28 42
     :return: A deduplicated list of flat dicts.
29 43
     """
30
-    # TODO: Keep track of found duplicates?
31 44
     # ``seen`` is a dict mapping aggregating the flats by the deduplication
32 45
     # keys. We basically make buckets of flats for every key value. Flats in
33 46
     # the same bucket should be merged together afterwards.
@@ -44,6 +57,18 @@ def detect(flats_list, key="id", merge=True):
44 57
             # of the others, to avoid over-deduplication.
45 58
             unique_flats_list.extend(matching_flats)
46 59
         else:
60
+            # Sort matching flats by backend precedence
61
+            matching_flats.sort(
62
+                key=lambda flat: next(
63
+                    i for (i, backend) in enumerate(BACKENDS_PRECEDENCE)
64
+                    if flat["id"].endswith(backend)
65
+                ),
66
+                reverse=True
67
+            )
68
+
69
+            if len(matching_flats) > 1:
70
+                LOGGER.info("Found duplicates: %s.",
71
+                            [flat["id"] for flat in matching_flats])
47 72
             # Otherwise, check the policy
48 73
             if merge:
49 74
                 # If a merge is requested, do the merge

+ 16
- 4
flatisfy/filters/metadata.py View File

@@ -20,14 +20,20 @@ LOGGER = logging.getLogger(__name__)
20 20
 def init(flats_list):
21 21
     """
22 22
     Create a flatisfy key containing a dict of metadata fetched by flatisfy for
23
-    each flat in the list.
23
+    each flat in the list. Also perform some basic transform on flat objects to
24
+    prepare for the metadata fetching.
24 25
 
25 26
     :param flats_list: A list of flats dict.
26 27
     :return: The updated list
27 28
     """
28 29
     for flat in flats_list:
30
+        # Init flatisfy key
29 31
         if "flatisfy" not in flat:
30 32
             flat["flatisfy"] = {}
33
+        # Move url key to urls
34
+        flat["urls"] = [flat["url"]]
35
+        # Create merged_ids key
36
+        flat["merged_ids"] = [flat["id"]]
31 37
     return flats_list
32 38
 
33 39
 
@@ -298,11 +304,17 @@ def guess_stations(flats_list, config, distance_threshold=1500):
298 304
         # If some stations were already filled in and the result is different,
299 305
         # display some warning to the user
300 306
         if (
301
-                "matched_stations" in flat["flatisfy"]["matched_stations"] and
307
+                "matched_stations" in flat["flatisfy"] and
302 308
                 (
303 309
                     # Do a set comparison, as ordering is not important
304
-                    set(flat["flatisfy"]["matched_stations"]) !=
305
-                    set(good_matched_stations)
310
+                    set([
311
+                        station["name"]
312
+                        for station in flat["flatisfy"]["matched_stations"]
313
+                    ]) !=
314
+                    set([
315
+                        station["name"]
316
+                        for station in good_matched_stations
317
+                    ])
306 318
                 )
307 319
         ):
308 320
             LOGGER.warning(

+ 53
- 11
flatisfy/models/flat.py View File

@@ -2,9 +2,12 @@
2 2
 """
3 3
 This modules defines an SQLAlchemy ORM model for a flat.
4 4
 """
5
-# pylint: disable=invalid-name,too-few-public-methods
5
+# pylint: disable=locally-disabled,invalid-name,too-few-public-methods
6 6
 from __future__ import absolute_import, print_function, unicode_literals
7 7
 
8
+import logging
9
+
10
+import arrow
8 11
 import enum
9 12
 
10 13
 from sqlalchemy import Column, DateTime, Enum, Float, String, Text
@@ -13,15 +16,29 @@ from flatisfy.database.base import BASE
13 16
 from flatisfy.database.types import MagicJSON
14 17
 
15 18
 
19
+LOGGER = logging.getLogger(__name__)
20
+
21
+
22
+class FlatUtilities(enum.Enum):
23
+    """
24
+    An enum of the possible utilities status for a flat entry.
25
+    """
26
+    included = 10
27
+    unknown = 0
28
+    excluded = -10
29
+
30
+
16 31
 class FlatStatus(enum.Enum):
17 32
     """
18 33
     An enum of the possible status for a flat entry.
19 34
     """
20
-    purged = -10
35
+    user_deleted = -100
36
+    ignored = -10
21 37
     new = 0
22
-    contacted = 10
23
-    answer_no = 20
24
-    answer_yes = 21
38
+    followed = 10
39
+    contacted = 20
40
+    answer_no = 30
41
+    answer_yes = 31
25 42
 
26 43
 
27 44
 class Flat(BASE):
@@ -36,6 +53,7 @@ class Flat(BASE):
36 53
     bedrooms = Column(Float)
37 54
     cost = Column(Float)
38 55
     currency = Column(String)
56
+    utilities = Column(Enum(FlatUtilities), default=FlatUtilities.unknown)
39 57
     date = Column(DateTime)
40 58
     details = Column(MagicJSON)
41 59
     location = Column(String)
@@ -45,7 +63,8 @@ class Flat(BASE):
45 63
     station = Column(String)
46 64
     text = Column(Text)
47 65
     title = Column(String)
48
-    url = Column(String)
66
+    urls = Column(MagicJSON)
67
+    merged_ids = Column(MagicJSON)
49 68
 
50 69
     # Flatisfy data
51 70
     # TODO: Should be in another table with relationships
@@ -65,25 +84,45 @@ class Flat(BASE):
65 84
         # Handle flatisfy metadata
66 85
         flat_dict = flat_dict.copy()
67 86
         flat_dict["flatisfy_stations"] = (
68
-            flat_dict["flatisfy"].get("matched_stations", None)
87
+            flat_dict["flatisfy"].get("matched_stations", [])
69 88
         )
70 89
         flat_dict["flatisfy_postal_code"] = (
71 90
             flat_dict["flatisfy"].get("postal_code", None)
72 91
         )
73 92
         flat_dict["flatisfy_time_to"] = (
74
-            flat_dict["flatisfy"].get("time_to", None)
93
+            flat_dict["flatisfy"].get("time_to", {})
75 94
         )
76 95
         del flat_dict["flatisfy"]
77 96
 
97
+        # Handle utilities field
98
+        if not isinstance(flat_dict["utilities"], FlatUtilities):
99
+            if flat_dict["utilities"] == "C.C.":
100
+                flat_dict["utilities"] = FlatUtilities.included
101
+            elif flat_dict["utilities"] == "H.C.":
102
+                flat_dict["utilities"] = FlatUtilities.excluded
103
+            else:
104
+                flat_dict["utilities"] = FlatUtilities.unknown
105
+
106
+        # Handle status field
107
+        flat_status = flat_dict.get("status", "new")
108
+        if not isinstance(flat_status, FlatStatus):
109
+            try:
110
+                flat_dict["status"] = getattr(FlatStatus, flat_status)
111
+            except AttributeError:
112
+                if "status" in flat_dict:
113
+                    del flat_dict["status"]
114
+                LOGGER.warn("Unkown flat status %s, ignoring it.",
115
+                            flat_status)
116
+
78 117
         # Handle date field
79
-        flat_dict["date"] = None  # TODO
118
+        flat_dict["date"] = arrow.get(flat_dict["date"]).naive
80 119
 
81 120
         flat_object = Flat()
82 121
         flat_object.__dict__.update(flat_dict)
83 122
         return flat_object
84 123
 
85 124
     def __repr__(self):
86
-        return "<Flat(id=%s, url=%s)>" % (self.id, self.url)
125
+        return "<Flat(id=%s, urls=%s)>" % (self.id, self.urls)
87 126
 
88 127
 
89 128
     def json_api_repr(self):
@@ -96,6 +135,9 @@ class Flat(BASE):
96 135
             for k, v in self.__dict__.items()
97 136
             if not k.startswith("_")
98 137
         }
99
-        flat_repr["status"] = str(flat_repr["status"])
138
+        if isinstance(flat_repr["status"], FlatStatus):
139
+            flat_repr["status"] = flat_repr["status"].name
140
+        if isinstance(flat_repr["utilities"], FlatUtilities):
141
+            flat_repr["utilities"] = flat_repr["utilities"].name
100 142
 
101 143
         return flat_repr

+ 52
- 18
flatisfy/tools.py View File

@@ -8,6 +8,7 @@ from __future__ import (
8 8
 )
9 9
 
10 10
 import datetime
11
+import itertools
11 12
 import json
12 13
 import logging
13 14
 import math
@@ -23,6 +24,16 @@ LOGGER = logging.getLogger(__name__)
23 24
 NAVITIA_ENDPOINT = "https://api.navitia.io/v1/coverage/fr-idf/journeys"
24 25
 
25 26
 
27
+class DateAwareJSONEncoder(json.JSONEncoder):
28
+    """
29
+    Extend the default JSON encoder to serialize datetimes to iso strings.
30
+    """
31
+    def default(self, o):  # pylint: disable=locally-disabled,E0202
32
+        if isinstance(o, (datetime.date, datetime.datetime)):
33
+            return o.isoformat()
34
+        return json.JSONEncoder.default(self, o)
35
+
36
+
26 37
 def pretty_json(data):
27 38
     """
28 39
     Pretty JSON output.
@@ -38,10 +49,25 @@ def pretty_json(data):
38 49
             "toto": "ok"
39 50
         }
40 51
     """
41
-    return json.dumps(data, indent=4, separators=(',', ': '),
52
+    return json.dumps(data, cls=DateAwareJSONEncoder,
53
+                      indent=4, separators=(',', ': '),
42 54
                       sort_keys=True)
43 55
 
44 56
 
57
+def batch(iterable, size):
58
+    """
59
+    Get items from a sequence a batch at a time.
60
+
61
+    :param iterable: The iterable to get the items from.
62
+    :param size: The size of the batches.
63
+    :return: A new iterable.
64
+    """
65
+    sourceiter = iter(iterable)
66
+    while True:
67
+        batchiter = itertools.islice(sourceiter, size)
68
+        yield itertools.chain([batchiter.next()], batchiter)
69
+
70
+
45 71
 def is_within_interval(value, min_value=None, max_value=None):
46 72
     """
47 73
     Check whether a variable is within a given interval. Assumes the value is
@@ -142,7 +168,7 @@ def distance(gps1, gps2):
142 168
     lat2 = math.radians(gps2[0])
143 169
     long2 = math.radians(gps2[1])
144 170
 
145
-    # pylint: disable=invalid-name
171
+    # pylint: disable=locally-disabled,invalid-name
146 172
     a = (
147 173
         math.sin((lat2 - lat1) / 2.0)**2 +
148 174
         math.cos(lat1) * math.cos(lat2) * math.sin((long2 - long1) / 2.0)**2
@@ -175,22 +201,30 @@ def merge_dicts(*args):
175 201
     """
176 202
     if len(args) == 1:
177 203
         return args[0]
178
-    else:
179
-        flat1, flat2 = args[:2]
180
-        merged_flat = {}
181
-        for k, value2 in flat2.items():
182
-            value1 = flat1.get(k, None)
183
-            if value1 is None:
184
-                # flat1 has empty matching field, just keep the flat2 field
185
-                merged_flat[k] = value2
186
-            elif value2 is None:
187
-                # flat2 field is empty, just keep the flat1 field
188
-                merged_flat[k] = value1
189
-            else:
190
-                # Any other case, we should merge
191
-                # TODO: Do the merge
192
-                merged_flat[k] = value1
193
-        return merge_dicts(merged_flat, *args[2:])
204
+
205
+    flat1, flat2 = args[:2]  # pylint: disable=locally-disabled,unbalanced-tuple-unpacking,line-too-long
206
+    merged_flat = {}
207
+    for k, value2 in flat2.items():
208
+        value1 = flat1.get(k, None)
209
+
210
+        if k in ["urls", "merged_ids"]:
211
+            # Handle special fields separately
212
+            merged_flat[k] = list(set(value2 + value1))
213
+            continue
214
+
215
+        if not value1:
216
+            # flat1 has empty matching field, just keep the flat2 field
217
+            merged_flat[k] = value2
218
+        elif not value2:
219
+            # flat2 field is empty, just keep the flat1 field
220
+            merged_flat[k] = value1
221
+        else:
222
+            # Any other case, we should keep the value of the more recent flat
223
+            # dict (the one most at right in arguments)
224
+            merged_flat[k] = value2
225
+    for k in [key for key in flat1.keys() if key not in flat2.keys()]:
226
+        merged_flat[k] = flat1[k]
227
+    return merge_dicts(merged_flat, *args[2:])
194 228
 
195 229
 
196 230
 def get_travel_time_between(latlng_from, latlng_to, config):

+ 35
- 0
flatisfy/web/app.py View File

@@ -6,15 +6,30 @@ from __future__ import (
6 6
     absolute_import, division, print_function, unicode_literals
7 7
 )
8 8
 
9
+import functools
10
+import json
11
+import logging
9 12
 import os
10 13
 
11 14
 import bottle
15
+import canister
12 16
 
13 17
 from flatisfy import database
18
+from flatisfy.tools import DateAwareJSONEncoder
14 19
 from flatisfy.web.routes import api as api_routes
20
+from flatisfy.web.configplugin import ConfigPlugin
15 21
 from flatisfy.web.dbplugin import DatabasePlugin
16 22
 
17 23
 
24
+class QuietWSGIRefServer(bottle.WSGIRefServer):
25
+    """
26
+    Quiet implementation of Bottle built-in WSGIRefServer, as `Canister` is
27
+    handling the logging through standard Python logging.
28
+    """
29
+    # pylint: disable=locally-disabled,too-few-public-methods
30
+    quiet = True
31
+
32
+
18 33
 def _serve_static_file(filename):
19 34
     """
20 35
     Helper function to serve static file.
@@ -38,11 +53,31 @@ def get_app(config):
38 53
 
39 54
     app = bottle.default_app()
40 55
     app.install(DatabasePlugin(get_session))
56
+    app.install(ConfigPlugin(config))
57
+    app.config.setdefault("canister.log_level", logging.root.level)
58
+    app.config.setdefault("canister.log_path", None)
59
+    app.config.setdefault("canister.debug", False)
60
+    app.install(canister.Canister())
61
+    # Use DateAwareJSONEncoder to dump JSON strings
62
+    # From http://stackoverflow.com/questions/21282040/bottle-framework-how-to-return-datetime-in-json-response#comment55718456_21282666.  pylint: disable=locally-disabled,line-too-long
63
+    bottle.install(
64
+        bottle.JSONPlugin(
65
+            json_dumps=functools.partial(json.dumps, cls=DateAwareJSONEncoder)
66
+        )
67
+    )
41 68
 
42 69
     # API v1 routes
43 70
     app.route("/api/v1/", "GET", api_routes.index_v1)
71
+
72
+    app.route("/api/v1/time_to/places", "GET", api_routes.time_to_places_v1)
73
+
44 74
     app.route("/api/v1/flats", "GET", api_routes.flats_v1)
75
+    app.route("/api/v1/flats/status/:status", "GET",
76
+              api_routes.flats_by_status_v1)
77
+
45 78
     app.route("/api/v1/flat/:flat_id", "GET", api_routes.flat_v1)
79
+    app.route("/api/v1/flat/:flat_id/status", "POST",
80
+              api_routes.update_flat_status_v1)
46 81
 
47 82
     # Index
48 83
     app.route("/", "GET", lambda: _serve_static_file("index.html"))

+ 72
- 0
flatisfy/web/configplugin.py View File

@@ -0,0 +1,72 @@
1
+# coding: utf-8
2
+"""
3
+This module contains a Bottle plugin to pass the config argument to any route
4
+which needs it.
5
+
6
+This module is heavily based on code from
7
+[Bottle-SQLAlchemy](https://github.com/iurisilvio/bottle-sqlalchemy) which is
8
+licensed under MIT license.
9
+"""
10
+from __future__ import (
11
+    absolute_import, division, print_function, unicode_literals
12
+)
13
+
14
+import functools
15
+import inspect
16
+
17
+import bottle
18
+
19
+
20
+class ConfigPlugin(object):
21
+    """
22
+    A Bottle plugin to automatically pass the config object to the routes
23
+    specifying they need it.
24
+    """
25
+    name = 'config'
26
+    api = 2
27
+    KEYWORD = "config"
28
+
29
+    def __init__(self, config):
30
+        """
31
+        :param config: The config object to pass.
32
+        """
33
+        self.config = config
34
+
35
+    def setup(self, app):  # pylint: disable=no-self-use
36
+        """
37
+        Make sure that other installed plugins don't affect the same
38
+        keyword argument and check if metadata is available.
39
+        """
40
+        for other in app.plugins:
41
+            if not isinstance(other, ConfigPlugin):
42
+                continue
43
+            else:
44
+                raise bottle.PluginError(
45
+                    "Found another conflicting Config plugin."
46
+                )
47
+
48
+    def apply(self, callback, route):
49
+        """
50
+        Method called on route invocation. Should apply some transformations to
51
+        the route prior to returing it.
52
+
53
+        We check the presence of ``self.KEYWORD`` in the route signature and
54
+        replace the route callback by a partial invocation where we replaced
55
+        this argument by a valid config object.
56
+        """
57
+        # Check whether the route needs a valid db session or not.
58
+        try:
59
+            callback_args = inspect.signature(route.callback).parameters
60
+        except AttributeError:
61
+            # inspect.signature does not exist on older Python
62
+            callback_args = inspect.getargspec(route.callback).args
63
+
64
+        if self.KEYWORD not in callback_args:
65
+            # If no need for a db session, call the route callback
66
+            return callback
67
+        kwargs = {}
68
+        kwargs[self.KEYWORD] = self.config
69
+        return functools.partial(callback, **kwargs)
70
+
71
+
72
+Plugin = ConfigPlugin

+ 10
- 7
flatisfy/web/dbplugin.py View File

@@ -28,13 +28,12 @@ class DatabasePlugin(object):
28 28
 
29 29
     def __init__(self, get_session):
30 30
         """
31
-        :param keyword: Keyword used to inject session database in a route
32 31
         :param create_session: SQLAlchemy session maker created with the
33 32
                 'sessionmaker' function. Will create its own if undefined.
34 33
         """
35 34
         self.get_session = get_session
36 35
 
37
-    def setup(self, app):  # pylint: disable-no-self-use
36
+    def setup(self, app):  # pylint: disable=no-self-use
38 37
         """
39 38
         Make sure that other installed plugins don't affect the same
40 39
         keyword argument and check if metadata is available.
@@ -67,11 +66,15 @@ class DatabasePlugin(object):
67 66
             # If no need for a db session, call the route callback
68 67
             return callback
69 68
         else:
70
-            # Otherwise, we get a db session and pass it to the callback
71
-            with self.get_session() as session:
72
-                kwargs = {}
73
-                kwargs[self.KEYWORD] = session
74
-                return functools.partial(callback, **kwargs)
69
+            def wrapper(*args, **kwargs):
70
+                """
71
+                Wrap the callback in a call to get_session.
72
+                """
73
+                with self.get_session() as session:
74
+                    # Get a db session and pass it to the callback
75
+                    kwargs[self.KEYWORD] = session
76
+                    return callback(*args, **kwargs)
77
+            return wrapper
75 78
 
76 79
 
77 80
 Plugin = DatabasePlugin

+ 63
- 0
flatisfy/web/js_src/api/index.js View File

@@ -0,0 +1,63 @@
1
+import moment from 'moment'
2
+
3
+require('es6-promise').polyfill()
4
+require('isomorphic-fetch')
5
+
6
+export const getFlats = function (callback) {
7
+    fetch('/api/v1/flats')
8
+    .then(function (response) {
9
+        return response.json()
10
+    }).then(function (json) {
11
+        const flats = json.data
12
+        flats.map(flat => {
13
+            if (flat.date) {
14
+                flat.date = moment(flat.date)
15
+            }
16
+            return flat
17
+        })
18
+        callback(flats)
19
+    }).catch(function (ex) {
20
+        console.error('Unable to parse flats: ' + ex)
21
+    })
22
+}
23
+
24
+export const getFlat = function (flatId, callback) {
25
+    fetch('/api/v1/flat/' + encodeURIComponent(flatId))
26
+    .then(function (response) {
27
+        return response.json()
28
+    }).then(function (json) {
29
+        const flat = json.data
30
+        if (flat.date) {
31
+            flat.date = moment(flat.date)
32
+        }
33
+        callback(json.data)
34
+    }).catch(function (ex) {
35
+        console.error('Unable to parse flats: ' + ex)
36
+    })
37
+}
38
+
39
+export const updateFlatStatus = function (flatId, newStatus, callback) {
40
+    fetch(
41
+        '/api/v1/flat/' + encodeURIComponent(flatId) + '/status',
42
+        {
43
+            method: 'POST',
44
+            headers: {
45
+                'Content-Type': 'application/json'
46
+            },
47
+            body: JSON.stringify({
48
+                status: newStatus
49
+            })
50
+        }
51
+    ).then(callback)
52
+}
53
+
54
+export const getTimeToPlaces = function (callback) {
55
+    fetch('/api/v1/time_to/places')
56
+    .then(function (response) {
57
+        return response.json()
58
+    }).then(function (json) {
59
+        callback(json.data)
60
+    }).catch(function (ex) {
61
+        console.error('Unable to fetch time to places: ' + ex)
62
+    })
63
+}

+ 76
- 0
flatisfy/web/js_src/components/app.vue View File

@@ -0,0 +1,76 @@
1
+<template>
2
+    <div>
3
+        <h1><router-link :to="{name: 'home'}">Flatisfy</router-link></h1>
4
+        <nav>
5
+            <ul>
6
+                <li><router-link :to="{name: 'home'}">{{ $t("menu.available_flats") }}</router-link></li>
7
+                <li><router-link :to="{name: 'followed'}">{{ $t("menu.followed_flats") }}</router-link></li>
8
+                <li><router-link :to="{name: 'ignored'}">{{ $t("menu.ignored_flats") }}</router-link></li>
9
+                <li><router-link :to="{name: 'user_deleted'}">{{ $t("menu.user_deleted_flats") }}</router-link></li>
10
+            </ul>
11
+        </nav>
12
+        <router-view></router-view>
13
+    </div>
14
+</template>
15
+
16
+<style>
17
+body {
18
+  margin: 0 auto;
19
+  max-width: 75em;
20
+  font-family: "Helvetica", "Arial", sans-serif;
21
+  line-height: 1.5;
22
+  padding: 4em 1em;
23
+  padding-top: 1em;
24
+  color: #555;
25
+}
26
+
27
+h1 {
28
+    text-align: center;
29
+}
30
+
31
+h1,
32
+h2,
33
+strong,
34
+th {
35
+  color: #333;
36
+}
37
+
38
+table {
39
+    border-collapse: collapse;
40
+    margin: 1em;
41
+    width: calc(100% - 2em);
42
+    text-align: center;
43
+}
44
+
45
+th, td {
46
+    padding: 1em;
47
+    border: 1px solid black;
48
+}
49
+
50
+tbody>tr:hover {
51
+    background-color: #DDD;
52
+}
53
+</style>
54
+
55
+<style scoped>
56
+h1 a {
57
+    color: inherit;
58
+    text-decoration: none;
59
+}
60
+
61
+nav {
62
+    text-align: center;
63
+}
64
+
65
+nav ul {
66
+    list-style-position: inside;
67
+    padding: 0;
68
+}
69
+
70
+nav ul li {
71
+    list-style: none;
72
+    display: inline-block;
73
+    padding-left: 1em;
74
+    padding-right: 1em;
75
+}
76
+</style>

+ 103
- 0
flatisfy/web/js_src/components/flatsmap.vue View File

@@ -0,0 +1,103 @@
1
+<template lang="html">
2
+    <div class="full">
3
+        <v-map :zoom="zoom.defaultZoom" :center="center" :bounds="bounds" :min-zoom="zoom.minZoom" :max-zoom="zoom.maxZoom">
4
+            <v-tilelayer :url="tiles.url" :attribution="tiles.attribution"></v-tilelayer>
5
+            <template v-for="marker in flats">
6
+                <v-marker :lat-lng="{ lat: marker.gps[0], lng: marker.gps[1] }" :icon="icons.flat">
7
+                    <v-popup :content="marker.content"></v-popup>
8
+                </v-marker>
9
+            </template>
10
+            <template v-for="(place_gps, place_name) in places">
11
+                <v-marker :lat-lng="{ lat: place_gps[0], lng: place_gps[1] }" :icon="icons.place">
12
+                    <v-tooltip :content="place_name"></v-tooltip>
13
+                </v-marker>
14
+            </template>
15
+        </v-map>
16
+    </div>
17
+</template>
18
+
19
+<script>
20
+import L from 'leaflet'
21
+import 'leaflet/dist/leaflet.css'
22
+import markerUrl from 'leaflet/dist/images/marker-icon.png'
23
+import marker2XUrl from 'leaflet/dist/images/marker-icon.png'
24
+import shadowUrl from 'leaflet/dist/images/marker-icon.png'
25
+
26
+require('leaflet.icon.glyph')
27
+
28
+import Vue2Leaflet from 'vue2-leaflet'
29
+
30
+export default {
31
+    data () {
32
+        return {
33
+            center: null,
34
+            zoom: {
35
+                defaultZoom: 13,
36
+                minZoom: 11,
37
+                maxZoom: 17
38
+            },
39
+            tiles: {
40
+                url: 'http://{s}.tile.osm.org/{z}/{x}/{y}.png',
41
+                attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
42
+            },
43
+            icons: {
44
+                flat: L.icon({
45
+                    iconUrl: '/static/js/' + markerUrl,
46
+                    iconRetinaUrl: '/static/js' + marker2XUrl,
47
+                    shadowUrl: '/static/js' + shadowUrl
48
+                }),
49
+                place: L.icon.glyph({
50
+                    prefix: 'fa',
51
+                    glyph: 'clock-o'
52
+                })
53
+            }
54
+        }
55
+    },
56
+
57
+    components: {
58
+        'v-map': Vue2Leaflet.Map,
59
+        'v-tilelayer': Vue2Leaflet.TileLayer,
60
+        'v-marker': Vue2Leaflet.Marker,
61
+        'v-tooltip': Vue2Leaflet.Tooltip,
62
+        'v-popup': Vue2Leaflet.Popup
63
+    },
64
+
65
+    computed: {
66
+        bounds () {
67
+            let bounds = []
68
+            this.flats.forEach(flat => bounds.push(flat.gps))
69
+            Object.keys(this.places).forEach(place => bounds.push(this.places[place]))
70
+
71
+            if (bounds.length > 0) {
72
+                bounds = L.latLngBounds(bounds)
73
+                return bounds
74
+            } else {
75
+                return null
76
+            }
77
+        }
78
+    },
79
+
80
+    props: ['flats', 'places']
81
+
82
+    // TODO: Add a switch to display a layer with isochrones
83
+}
84
+</script>
85
+
86
+<style lang="css">
87
+.leaflet-popup-content {
88
+    max-height: 20vh;
89
+    overflow-y: auto;
90
+}
91
+</style>
92
+
93
+<style lang="css" scoped>
94
+.full {
95
+    width: 100%;
96
+    height: 75vh;
97
+    background-color: #ddd;
98
+}
99
+
100
+#map {
101
+    height: 100%;
102
+}
103
+</style>

+ 90
- 0
flatisfy/web/js_src/components/flatstable.vue View File

@@ -0,0 +1,90 @@
1
+<template lang="html">
2
+    <table>
3
+        <thead>
4
+            <tr>
5
+                <th>{{ $t("flatsDetails.Title") }}</th>
6
+                <th>{{ $t("flatsDetails.Area") }}</th>
7
+                <th>{{ $t("flatsDetails.Rooms") }}</th>
8
+                <th>{{ $t("flatsDetails.Cost") }}</th>
9
+                <th>{{ $t("common.Actions") }}</th>
10
+            </tr>
11
+        </thead>
12
+        <tbody>
13
+            <tr v-for="flat in sortedFlats" :key="flat.id">
14
+                <td>
15
+                    [{{ flat.id.split("@")[1] }}] {{ flat.title }}
16
+
17
+                    <template v-if="flat.photos && flat.photos.length > 0">
18
+                        <br/>
19
+                        <img :src="flat.photos[0].url"/>
20
+                    </template>
21
+                </td>
22
+                <td>{{ flat.area }} m²</td>
23
+                <td>
24
+                    {{ flat.rooms ? flat.rooms : '?'}}
25
+                </td>
26
+                <td>
27
+                    {{ flat.cost }} {{ flat.currency }}
28
+                    <template v-if="flat.utilities == 'included'">
29
+                        {{ $t("flatsDetails.utilities_included") }}
30
+                    </template>
31
+                    <template v-else-if="flat.utilities == 'excluded'">
32
+                        {{ $t("flatsDetails.utilities_excluded") }}
33
+                    </template>
34
+                </td>
35
+                <td>
36
+                    <router-link :to="{name: 'details', params: {id: flat.id}}" :aria-label="$t('common.More')" :title="$t('common.More')">
37
+                        <i class="fa fa-plus" aria-hidden="true"></i>
38
+                    </router-link>
39
+                    <a :href="flat.urls[0]" :aria-label="$t('common.External_link')" :title="$t('common.External_link')" target="_blank">
40
+                        <i class="fa fa-external-link" aria-hidden="true"></i>
41
+                    </a>
42
+                    <button v-if="flat.status !== 'user_deleted'" v-on:click="updateFlatStatus(flat.id, 'user_deleted')" :aria-label="$t('common.Remove')" :title="$t('common.Remove')">
43
+                        <i class="fa fa-trash" aria-hidden="true"></i>
44
+                    </button>
45
+                    <button v-else v-on:click="updateFlatStatus(flat.id, 'new')" :aria-label="$t('common.Restore')" :title="$t('common.Restore')">
46
+                        <i class="fa fa-undo" aria-hidden="true"></i>
47
+                    </button>
48
+                </td>
49
+            </tr>
50
+        </tbody>
51
+    </table>
52
+</template>
53
+
54
+<script>
55
+export default {
56
+    props: ['flats'],
57
+
58
+    computed: {
59
+        sortedFlats () {
60
+            return this.flats.sort((flat1, flat2) => flat1.cost - flat2.cost)
61
+        }
62
+    },
63
+
64
+    methods: {
65
+        updateFlatStatus (id, status) {
66
+            this.$store.dispatch('updateFlatStatus', { flatId: id, newStatus: status })
67
+        }
68
+    }
69
+}
70
+</script>
71
+
72
+<style scoped>
73
+td a {
74
+    display: inline-block;
75
+    padding-left: 5px;
76
+    padding-right: 5px;
77
+    color: inherit;
78
+}
79
+
80
+td img {
81
+    max-height: 100px;
82
+}
83
+
84
+button {
85
+    border: none;
86
+    background: transparent;
87
+    font-size: 1em;
88
+    cursor: pointer;
89
+}
90
+</style>

+ 144
- 0
flatisfy/web/js_src/components/slider.vue View File

@@ -0,0 +1,144 @@
1
+<template>
2
+<div @keydown="closeModal">
3
+    <isotope ref="cpt" :options="isotopeOptions" v-images-loaded:on.progress="layout" :list="photos">
4
+        <div v-for="(photo, index) in photos" :key="photo.url">
5
+            <img :src="photo.url" v-on:click="openModal(index)"/>
6
+        </div>
7
+    </isotope>
8
+
9
+    <div class="modal" ref="modal" :aria-label="$t('slider.Fullscreen_photo')" role="dialog">
10
+        <span class="close"><button v-on:click="closeModal" :title="$t('common.Close')" :aria-label="$t('common.Close')">&times;</button></span>
11
+
12
+        <img class="modal-content" :src="photos[modalImgIndex].url">
13
+    </div>
14
+</div>
15
+</template>
16
+
17
+<script>
18
+import isotope from 'vueisotope'
19
+import imagesLoaded from 'vue-images-loaded'
20
+
21
+export default {
22
+    props: [
23
+        'photos'
24
+    ],
25
+
26
+    components: {
27
+        isotope
28
+    },
29
+
30
+    created () {
31
+        window.addEventListener('keydown', event => {
32
+            if (!this.isModalOpen) {
33
+                return
34
+            }
35
+
36
+            if (event.key === 'Escape') {
37
+                this.closeModal()
38
+            } else if (event.key === 'ArrowLeft') {
39
+                this.modalImgIndex = Math.max(
40
+                    this.modalImgIndex - 1,
41
+                    0
42
+                )
43
+            } else if (event.key === 'ArrowRight') {
44
+                this.modalImgIndex = Math.min(
45
+                    this.modalImgIndex + 1,
46
+                    this.photos.length - 1
47
+                )
48
+            }
49
+        })
50
+    },
51
+
52
+    directives: {
53
+        imagesLoaded
54
+    },
55
+
56
+    data () {
57
+        return {
58
+            'isotopeOptions': {
59
+                layoutMode: 'masonry',
60
+                masonry: {
61
+                    columnWidth: 275
62
+                }
63
+            },
64
+            'isModalOpen': false,
65
+            'modalImgIndex': 0
66
+        }
67
+    },
68
+
69
+    methods: {
70
+        layout () {
71
+            this.$refs.cpt.layout('masonry')
72
+        },
73
+
74
+        openModal (index) {
75
+            this.isModalOpen = true
76
+            this.modalImgIndex = index
77
+
78
+            this.$refs.modal.style.display = 'block'
79
+        },
80
+
81
+        closeModal () {
82
+            this.isModalOpen = false
83
+            this.$refs.modal.style.display = 'none'
84
+        }
85
+    }
86
+}
87
+</script>
88
+
89
+<style scoped>
90
+.item img {
91
+    max-width: 250px;
92
+    margin: 10px;
93
+    cursor: pointer;
94
+}
95
+
96
+.item img:hover {
97
+    opacity: 0.7;
98
+}
99
+
100
+.modal {
101
+    display: none;
102
+    position: fixed;
103
+    z-index: 10000;
104
+    padding-top: 100px;
105
+    left: 0;
106
+    top: 0;
107
+    width: 100%;
108
+    height: 100%;
109
+    overflow: auto;
110
+    background-color: rgb(0,0,0);
111
+    background-color: rgba(0,0,0,0.9);
112
+}
113
+
114
+.modal-content {
115
+    margin: auto;
116
+    display: block;
117
+    height: 80%;
118
+    max-width: 700px;
119
+}
120
+
121
+.close {
122
+    position: absolute;
123
+    top: 15px;
124
+    right: 35px;
125
+    color: #f1f1f1;
126
+    font-size: 40px;
127
+    font-weight: bold;
128
+    transition: 0.3s;
129
+}
130
+
131
+.close button {
132
+    font-size: 1em;
133
+    border: none;
134
+    background: transparent;
135
+    cursor: pointer;
136
+}
137
+
138
+.close:hover,
139
+.close:focus {
140
+    color: #bbb;
141
+    text-decoration: none;
142
+    cursor: pointer;
143
+}
144
+</style>

+ 55
- 0
flatisfy/web/js_src/i18n/en/index.js View File

@@ -0,0 +1,55 @@
1
+export default {
2
+    common: {
3
+        'flats': 'flat | flats',
4
+        'loading': 'Loading…',
5
+        'Actions': 'Actions',
6
+        'More': 'More',
7
+        'Remove': 'Remove',
8
+        'Restore': 'Restore',
9
+        'External_link': 'External link',
10
+        'Follow': 'Follow',
11
+        'Close': 'Close'
12
+    },
13
+    home: {
14
+        'new_available_flats': 'New available flats'
15
+    },
16
+    flatListing: {
17
+        'no_available_flats': 'No available flats.'
18
+    },
19
+    menu: {
20
+        'available_flats': 'Available flats',
21
+        'followed_flats': 'Followed flats',
22
+        'ignored_flats': 'Ignored flats',
23
+        'user_deleted_flats': 'User deleted flats'
24
+    },
25
+    flatsDetails: {
26
+        'Title': 'Title',
27
+        'Area': 'Area',
28
+        'Rooms': 'Rooms',
29
+        'Cost': 'Cost',
30
+        'utilities_included': '(utilities included)',
31
+        'utilities_excluded': '(utilities excluded)',
32
+        'Description': 'Description',
33
+        'Details': 'Details',
34
+        'Metadata': 'Metadata',
35
+        'postal_code': 'Postal code',
36
+        'nearby_stations': 'Nearby stations',
37
+        'Times_to': 'Times to',
38
+        'Location': 'Location',
39
+        'Contact': 'Contact',
40
+        'no_phone_found': 'No phone found',
41
+        'Original_posts': 'Original posts:',
42
+        'Original_post': 'Original post',
43
+        'rooms': 'room | rooms',
44
+        'bedrooms': 'bedroom | bedrooms'
45
+    },
46
+    status: {
47
+        'new': 'new',
48
+        'followed': 'followed',
49
+        'ignored': 'ignored',
50
+        'user_deleted': 'user deleted'
51
+    },
52
+    slider: {
53
+        'Fullscreen_photo': 'Fullscreen photo'
54
+    }
55
+}

+ 52
- 0
flatisfy/web/js_src/i18n/index.js View File

@@ -0,0 +1,52 @@
1
+import Vue from 'vue'
2
+import VueI18n from 'vue-i18n'
3
+
4
+// Import translations
5
+import en from './en'
6
+
7
+Vue.use(VueI18n)
8
+
9
+export function getBrowserLocales () {
10
+    let langs = []
11
+
12
+    if (navigator.languages) {
13
+        // Chrome does not currently set navigator.language correctly
14
+        // https://code.google.com/p/chromium/issues/detail?id=101138
15
+        // but it does set the first element of navigator.languages correctly
16
+        langs = navigator.languages
17
+    } else if (navigator.userLanguage) {
18
+        // IE only
19
+        langs = [navigator.userLanguage]
20
+    } else {
21
+        // as of this writing the latest version of firefox + safari set this correctly
22
+        langs = [navigator.language]
23
+    }
24
+
25
+    // Some browsers does not return uppercase for second part
26
+    const locales = langs.map(function (lang) {
27
+        const locale = lang.split('-')
28
+        return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang
29
+    })
30
+
31
+    return locales
32
+}
33
+
34
+const messages = {
35
+    'en': en
36
+}
37
+
38
+const locales = getBrowserLocales()
39
+
40
+var locale = 'en'  // Safe default
41
+// Get best matching locale
42
+for (var i = 0; i < locales.length; ++i) {
43
+    if (messages[locales[i]]) {
44
+        locale = locales[i]
45
+        break  // Break at first matching locale
46
+    }
47
+}
48
+
49
+export default new VueI18n({
50
+    locale: locale,
51
+    messages
52
+})

+ 14
- 0
flatisfy/web/js_src/main.js View File

@@ -0,0 +1,14 @@
1
+import Vue from 'vue'
2
+
3
+import i18n from './i18n'
4
+import router from './router'
5
+import store from './store'
6
+
7
+import App from './components/app.vue'
8
+
9
+new Vue({
10
+    i18n,
11
+    router,
12
+    store,
13
+    render: createEle => createEle(App)
14
+}).$mount('#app')

+ 18
- 0
flatisfy/web/js_src/router/index.js View File

@@ -0,0 +1,18 @@
1
+import Vue from 'vue'
2
+import VueRouter from 'vue-router'
3
+
4
+import Home from '../views/home.vue'
5
+import Status from '../views/status.vue'
6
+import Details from '../views/details.vue'
7
+
8
+Vue.use(VueRouter)
9
+
10
+export default new VueRouter({
11
+    routes: [
12
+      { path: '/', component: Home, name: 'home' },
13
+      { path: '/followed', component: Status, name: 'followed' },
14
+      { path: '/ignored', component: Status, name: 'ignored' },
15
+      { path: '/user_deleted', component: Status, name: 'user_deleted' },
16
+      { path: '/flat/:id', component: Details, name: 'details' }
17
+    ]
18
+})

+ 26
- 0
flatisfy/web/js_src/store/actions.js View File

@@ -0,0 +1,26 @@
1
+import * as api from '../api'
2
+import * as types from './mutations-types'
3
+
4
+export default {
5
+    getAllFlats ({ commit }) {
6
+        api.getFlats(flats => {
7
+            commit(types.REPLACE_FLATS, { flats })
8
+        })
9
+    },
10
+    getFlat ({ commit }, { flatId }) {
11
+        api.getFlat(flatId, flat => {
12
+            const flats = [flat]
13
+            commit(types.MERGE_FLATS, { flats })
14
+        })
15
+    },
16
+    getAllTimeToPlaces ({ commit }) {
17
+        api.getTimeToPlaces(timeToPlaces => {
18
+            commit(types.RECEIVE_TIME_TO_PLACES, { timeToPlaces })
19
+        })
20
+    },
21
+    updateFlatStatus ({ commit }, { flatId, newStatus }) {
22
+        api.updateFlatStatus(flatId, newStatus, response => {
23
+            commit(types.UPDATE_FLAT_STATUS, { flatId, newStatus })
24
+        })
25
+    }
26
+}

+ 56
- 0
flatisfy/web/js_src/store/getters.js View File

@@ -0,0 +1,56 @@
1
+import { findFlatGPS } from '../tools'
2
+
3
+export default {
4
+    allFlats: state => state.flats,
5
+
6
+    flat: (state, getters) => id => state.flats.find(flat => flat.id === id),
7
+
8
+    postalCodesFlatsBuckets: (state, getters) => filter => {
9
+        const postalCodeBuckets = {}
10
+
11
+        state.flats.forEach(flat => {
12
+            if (filter && filter(flat)) {
13
+                const postalCode = flat.flatisfy_postal_code.postal_code
14
+                if (!postalCodeBuckets[postalCode]) {
15
+                    postalCodeBuckets[postalCode] = {
16
+                        'name': flat.flatisfy_postal_code.name,
17
+                        'flats': []
18
+                    }
19
+                }
20
+                postalCodeBuckets[postalCode].flats.push(flat)
21
+            }
22
+        })
23
+
24
+        return postalCodeBuckets
25
+    },
26
+
27
+    flatsMarkers: (state, getters) => (router, filter) => {
28
+        const markers = []
29
+        state.flats.forEach(flat => {
30
+            if (filter && filter(flat)) {
31
+                const gps = findFlatGPS(flat)
32
+
33
+                if (gps) {
34
+                    const previousMarkerIndex = markers.findIndex(
35
+                        marker => marker.gps[0] === gps[0] && marker.gps[1] === gps[1]
36
+                    )
37
+
38
+                    const href = router.resolve({ name: 'details', params: { id: flat.id }}).href
39
+                    if (previousMarkerIndex !== -1) {
40
+                        markers[previousMarkerIndex].content += '<br/><a href="' + href + '">' + flat.title + '</a>'
41
+                    } else {
42
+                        markers.push({
43
+                            'title': '',
44
+                            'content': '<a href="' + href + '">' + flat.title + '</a>',
45
+                            'gps': gps
46
+                        })
47
+                    }
48
+                }
49
+            }
50
+        })
51
+
52
+        return markers
53
+    },
54
+
55
+    allTimeToPlaces: state => state.timeToPlaces
56
+}

+ 16
- 0
flatisfy/web/js_src/store/index.js View File

@@ -0,0 +1,16 @@
1
+import Vue from 'vue'
2
+import Vuex from 'vuex'
3
+
4
+import actions from './actions'
5
+import getters from './getters'
6
+import { state, mutations } from './mutations'
7
+// import products from './modules/products'
8
+
9
+Vue.use(Vuex)
10
+
11
+export default new Vuex.Store({
12
+    state,
13
+    actions,
14
+    getters,
15
+    mutations
16
+})

+ 4
- 0
flatisfy/web/js_src/store/mutations-types.js View File

@@ -0,0 +1,4 @@
1
+export const REPLACE_FLATS = 'REPLACE_FLATS'
2
+export const MERGE_FLATS = 'MERGE_FLATS'
3
+export const UPDATE_FLAT_STATUS = 'UPDATE_FLAT_STATUS'
4
+export const RECEIVE_TIME_TO_PLACES = 'RECEIVE_TIME_TO_PLACES'

+ 34
- 0
flatisfy/web/js_src/store/mutations.js View File

@@ -0,0 +1,34 @@
1
+import Vue from 'vue'
2
+
3
+import * as types from './mutations-types'
4
+
5
+export const state = {
6
+    flats: [],
7
+    timeToPlaces: []
8
+}
9
+
10
+export const mutations = {
11
+    [types.REPLACE_FLATS] (state, { flats }) {
12
+        state.flats = flats
13
+    },
14
+    [types.MERGE_FLATS] (state, { flats }) {
15
+        flats.forEach(flat => {
16
+            const flatIndex = state.flats.findIndex(storedFlat => storedFlat.id === flat.id)
17
+
18
+            if (flatIndex > -1) {
19
+                Vue.set(state.flats, flatIndex, flat)
20
+            } else {
21
+                state.flats.push(flat)
22
+            }
23
+        })
24
+    },
25
+    [types.UPDATE_FLAT_STATUS] (state, { flatId, newStatus }) {
26
+        const index = state.flats.findIndex(flat => flat.id === flatId)
27
+        if (index > -1) {
28
+            Vue.set(state.flats[index], 'status', newStatus)
29
+        }
30
+    },
31
+    [types.RECEIVE_TIME_TO_PLACES] (state, { timeToPlaces }) {
32
+        state.timeToPlaces = timeToPlaces
33
+    }
34
+}

+ 21
- 0
flatisfy/web/js_src/tools/index.js View File

@@ -0,0 +1,21 @@
1
+export function findFlatGPS (flat) {
2
+    let gps
3
+
4
+    // Try to push a marker based on stations
5
+    if (flat.flatisfy_stations && flat.flatisfy_stations.length > 0) {
6
+        gps = [0.0, 0.0]
7
+        flat.flatisfy_stations.forEach(station => {
8
+            gps = [gps[0] + station.gps[0], gps[1] + station.gps[1]]
9
+        })
10
+        gps = [gps[0] / flat.flatisfy_stations.length, gps[1] / flat.flatisfy_stations.length]
11
+    } else {
12
+        // Else, push a marker based on postal code
13
+        gps = flat.flatisfy_postal_code.gps
14
+    }
15
+
16
+    return gps
17
+}
18
+
19
+export function capitalize (string) {
20
+    return string.charAt(0).toUpperCase() + string.slice(1)
21
+}

+ 275
- 0
flatisfy/web/js_src/views/details.vue View File

@@ -0,0 +1,275 @@
1
+<template>
2
+    <div>
3
+        <div class="grid" v-if="flat && timeToPlaces">
4
+            <div class="left-panel">
5
+                <h2>
6
+                    <a v-on:click="goBack" class="link">
7
+                        <i class="fa fa-arrow-left" aria-hidden="true"></i>
8
+                    </a>
9
+                    ({{ flat.status ? capitalize(flat.status) : '' }}) {{ flat.title }} [{{ flat.id.split("@")[1] }}]
10
+                </h2>
11
+                <div class="grid">
12
+                    <div class="left-panel">
13
+                        <p>
14
+                            {{ flat.cost }} {{ flat.currency }}
15
+                            <template v-if="flat.utilities === 'included'">
16
+                                {{ $t("flatsDetails.utilities_included") }}
17
+                            </template>
18
+                            <template v-else-if="flat.utilities === 'excluded'">
19
+                                {{ $t("flatsDetails.utilities_excluded") }}
20
+                            </template>
21
+                        </p>
22
+                    </div>
23
+                    <p class="right-panel right">
24
+                        {{ flat.area ? flat.area : '?' }} m<sup>2</sup>,
25
+                        {{ flat.rooms ? flat.rooms : '?' }} {{ $tc("flatsDetails.rooms", flat.rooms) }} /
26
+                        {{ flat.bedrooms ? flat.bedrooms : '?' }} {{ $tc("flatsDetails.bedrooms", flat.bedrooms) }}
27
+                    </p>
28
+                </div>
29
+                <div>
30
+                    <template v-if="flat.photos && flat.photos.length > 0">
31
+                        <Slider :photos="flat.photos"></Slider>
32
+                    </template>
33
+                </div>
34
+                <div>
35
+                    <h3>{{ $t("flatsDetails.Description") }}</h3>
36
+                    <p>{{ flat.text }}</p>
37
+                    <p class="right">{{ flat.location }}</p>
38
+                    <p>First posted {{ flat.date ? flat.date.fromNow() : '?' }}.</p>
39
+                </div>
40
+                <div>
41
+                    <h3>{{ $t("flatsDetails.Details") }}</h3>
42
+                    <table>
43
+                        <tr v-for="(value, key) in flat.details">
44
+                            <th>{{ key }}</th>
45
+                            <td>{{ value }}</td>
46
+                        </tr>
47
+                    </table>
48
+                </div>
49
+                <div>
50
+                    <h3>{{ $t("flatsDetails.Metadata") }}</h3>
51
+                    <table>
52
+                        <tr>
53
+                            <th>
54
+                                {{ $t("flatsDetails.postal_code") }}
55
+                            </th>
56
+                            <td>
57
+                                <template v-if="flat.flatisfy_postal_code.postal_code">
58
+                                    {{ flat.flatisfy_postal_code.name }} ( {{ flat.flatisfy_postal_code.postal_code }} )
59
+                                </template>
60
+                                <template v-else>
61
+                                    ?
62
+                                </template>
63
+                            </td>
64
+                        </tr>
65
+
66
+                        <tr>
67
+                            <th>
68
+                                {{ $t("flatsDetails.nearby_stations") }}
69
+                            </th>
70
+                            <td>
71
+                                <template v-if="displayedStations">
72
+                                    {{ displayedStations }}
73
+                                </template>
74
+                                <template v-else>
75
+                                    ?
76
+                                </template>
77
+                            </td>
78
+                        </tr>
79
+                        <tr>
80
+                            <th>
81
+                                {{ $t("flatsDetails.Times_to") }}
82
+                            </th>
83
+                            <td>
84
+                                <template v-if="Object.keys(flat.flatisfy_time_to).length">
85
+                                    <ul class="time_to_list">
86
+                                        <li v-for="(time_to, place) in flat.flatisfy_time_to" :key="place">
87
+                                            {{ place }}: {{ time_to }}
88
+                                        </li>
89
+                                    </ul>
90
+                                </template>
91
+                                <template v-else>
92
+                                    ?
93
+                                </template>
94
+                            </td>
95
+                        </tr>
96
+                    </table>
97
+                </div>
98
+                <div>
99
+                    <h3>{{ $t("flatsDetails.Location") }}</h3>
100
+
101
+                    <FlatsMap :flats="flatMarkers" :places="timeToPlaces"></FlatsMap>
102
+                </div>
103
+            </div>
104
+            <div class="right-panel">
105
+                <h3>{{ $t("flatsDetails.Contact") }}</h3>
106
+                <div class="contact">
107
+                    <p>
108
+                        <a v-if="flat.phone" :href="'tel:+33' + flat.phone">{{ flat.phone }}</a>
109
+                        <template v-else>
110
+                            {{ $t("flatsDetails.no_phone_found") }}
111
+                        </template>
112
+                    </p>
113
+                    <p>{{ $t("flatsDetails.Original_posts") }}
114
+                        <ul>
115
+                            <li v-for="(url, index) in flat.urls">
116
+                                <a :href="url">
117
+                                    {{ $t("flatsDetails.Original_post") }} {{ index + 1 }}
118
+                                    <i class="fa fa-external-link" aria-hidden="true"></i>
119
+                                </a>
120
+                            </li>
121
+                        </ul>
122
+                    </p>
123
+                </div>
124
+
125
+                <h3>{{ $t("common.Actions") }}</h3>
126
+
127
+                <nav>
128
+                    <ul>
129
+                        <template v-if="flat.status !== 'user_deleted'">
130
+                            <li>
131
+                                <button v-on:click="updateFlatStatus('follow')">
132
+                                    <i class="fa fa-star" aria-hidden="true"></i>
133
+                                    {{ $t("common.Follow") }}
134
+                                </button>
135
+                            </li>
136
+                            <li>
137
+                                <button v-on:click="updateFlatStatus('user_deleted')">
138
+                                    <i class="fa fa-trash" aria-hidden="true"></i>
139
+                                    {{ $t("common.Remove") }}
140
+                                </button>
141
+                            </li>
142
+                        </template>
143
+                        <template v-else>
144
+                            <li>
145
+                                <button v-on:click="updateFlatStatus('new')">
146
+                                    <i class="fa fa-undo" aria-hidden="true"></i>
147
+                                    {{ $t("common.Restore") }}
148
+                                </button>
149
+                            </li>
150
+                        </template>
151
+                    </ul>
152
+                </nav>
153
+            </div>
154
+        </div>
155
+        <template v-else>
156
+            <p>{{ $t("common.loading") }}</p>
157
+        </template>
158
+    </div>
159
+</template>
160
+
161
+<script>
162
+import FlatsMap from '../components/flatsmap.vue'
163
+import Slider from '../components/slider.vue'
164
+
165
+import { capitalize } from '../tools'
166
+
167
+export default {
168
+    components: {
169
+        FlatsMap,
170
+        Slider
171
+    },
172
+
173
+    created () {
174
+        // Fetch data when the component is created
175
+        this.fetchData()
176
+
177
+        // Scrolls to top when view is displayed
178
+        window.scrollTo(0, 0)
179
+    },
180
+
181
+    watch: {
182
+        // Fetch data again when the component is updated
183
+        '$route': 'fetchData'
184
+    },
185
+
186
+    computed: {
187
+        flatMarkers () {
188
+            return this.$store.getters.flatsMarkers(this.$router, flat => flat.id === this.$route.params.id)
189
+        },
190
+        timeToPlaces () {
191
+            return this.$store.getters.allTimeToPlaces
192
+        },
193
+        flat () {
194
+            return this.$store.getters.flat(this.$route.params.id)
195
+        },
196
+        displayedStations () {
197
+            if (this.flat.flatisfy_stations.length > 0) {
198
+                const stationsNames = this.flat.flatisfy_stations.map(station => station.name)
199
+                return stationsNames.join(', ')
200
+            } else {
201
+                return null
202
+            }
203
+        }
204
+    },
205
+
206
+    methods: {
207
+        fetchData () {
208
+            this.$store.dispatch('getFlat', { flatId: this.$route.params.id })
209
+            this.$store.dispatch('getAllTimeToPlaces')
210
+        },
211
+
212
+        goBack () {
213
+            return this.$router.go(-1)
214
+        },
215
+
216
+        updateFlatStatus (status) {
217
+            this.$store.dispatch('updateFlatStatus', { flatId: this.$route.params.id, newStatus: status })
218
+        },
219
+
220
+        capitalize: capitalize
221
+    }
222
+}
223
+</script>
224
+
225
+<style scoped>
226
+.grid {
227
+    display: grid;
228
+    grid-gap: 50px;
229
+}
230
+
231
+.left-panel {
232
+    grid-column: 1;
233
+    grid-row: 1;
234
+}
235
+
236
+.right-panel {
237
+    grid-column: 2;
238
+    grid-row: 1;
239
+}
240
+
241
+.right {
242
+    text-align: right;
243
+}
244
+
245
+nav ul {
246
+    list-style-type: none;
247
+    padding-left: 1em;
248
+}
249
+
250
+.contact {
251
+    padding-left: 1em;
252
+}
253
+
254
+.link {
255
+    cursor: pointer;
256
+}
257
+
258
+.right-panel li {
259
+    margin-bottom: 1em;
260
+    margin-top: 1em;
261
+}
262
+
263
+button {
264
+    cursor: pointer;
265
+    width: 75%;
266
+    padding: 0.3em;
267
+    font-size: 0.9em;
268
+}
269
+
270
+.time_to_list {
271
+    margin: 0;
272
+    padding-left: 0;
273
+    list-style-position: outside;
274
+}
275
+</style>

+ 52
- 0
flatisfy/web/js_src/views/home.vue View File

@@ -0,0 +1,52 @@
1
+<template>
2
+    <div>
3
+        <template v-if="postalCodesFlatsBuckets && flatsMarkers">
4
+            <FlatsMap :flats="flatsMarkers" :places="timeToPlaces"></FlatsMap>
5
+
6
+            <h2>{{ $t("home.new_available_flats") }}</h2>
7
+            <template v-if="postalCodesFlatsBuckets">
8
+                <template v-for="(postal_code_data, postal_code) in postalCodesFlatsBuckets">
9
+                    <h3>{{ postal_code_data.name }} ({{ postal_code }}) - {{ postal_code_data.flats.length }} {{ $tc("common.flats", 42) }}</h3>
10
+                    <FlatsTable :flats="postal_code_data.flats"></FlatsTable>
11
+                </template>
12
+            </template>
13
+            <template v-else>
14
+                <p>{{ $t("flatListing.no_available_flats") }}</p>
15
+            </template>
16
+        </template>
17
+        <template v-else>
18
+            <p>{{ $t("common.loading") }}</p>
19
+        </template>
20
+    </div>
21
+</template>
22
+
23
+<script>
24
+import FlatsMap from '../components/flatsmap.vue'
25
+import FlatsTable from '../components/flatstable.vue'
26
+
27
+export default {
28
+    components: {
29
+        FlatsMap,
30
+        FlatsTable
31
+    },
32
+
33
+    created () {
34
+        // Fetch flats when the component is created
35
+        this.$store.dispatch('getAllFlats')
36
+        // Fetch time to places when the component is created
37
+        this.$store.dispatch('getAllTimeToPlaces')
38
+    },
39
+
40
+    computed: {
41
+        postalCodesFlatsBuckets () {
42
+            return this.$store.getters.postalCodesFlatsBuckets(flat => flat.status === 'new')
43
+        },
44
+        flatsMarkers () {
45
+            return this.$store.getters.flatsMarkers(this.$router, flat => flat.status === 'new')
46
+        },
47
+        timeToPlaces () {
48
+            return this.$store.getters.allTimeToPlaces
49
+        }
50
+    }
51
+}
52
+</script>

+ 41
- 0
flatisfy/web/js_src/views/status.vue View File

@@ -0,0 +1,41 @@
1
+<template>
2
+    <div>
3
+        <h2>{{ capitalize($t("status." + $route.name)) }}</h2>
4
+        <template v-if="Object.keys(postalCodesFlatsBuckets).length">
5
+            <template v-for="(postal_code_data, postal_code) in postalCodesFlatsBuckets">
6
+                <h3>{{ postal_code_data.name }} ({{ postal_code }}) - {{ postal_code_data.flats.length }} {{ $tc("common.flats", 42) }}</h3>
7
+                <FlatsTable :flats="postal_code_data.flats"></FlatsTable>
8
+            </template>
9
+        </template>
10
+        <template v-else>
11
+            <p>{{ $t("flatListing.no_available_flats") }}</p>
12
+        </template>
13
+    </div>
14
+</template>
15
+
16
+<script>
17
+import { capitalize } from '../tools'
18
+
19
+import FlatsTable from '../components/flatstable.vue'
20
+
21
+export default {
22
+    components: {
23
+        FlatsTable
24
+    },
25
+
26
+    created () {
27
+        // Fetch flats when the component is created
28
+        this.$store.dispatch('getAllFlats')
29
+    },
30
+
31
+    computed: {
32
+        postalCodesFlatsBuckets () {
33
+            return this.$store.getters.postalCodesFlatsBuckets(flat => flat.status === this.$route.name)
34
+        }
35
+    },
36
+
37
+    methods: {
38
+        capitalize: capitalize
39
+    }
40
+}
41
+</script>

+ 116
- 3
flatisfy/web/routes/api.py View File

@@ -6,6 +6,11 @@ from __future__ import (
6 6
     absolute_import, division, print_function, unicode_literals
7 7
 )
8 8
 
9
+import json
10
+
11
+import bottle
12
+
13
+import flatisfy.data
9 14
 from flatisfy.models import flat as flat_model
10 15
 
11 16
 
@@ -20,28 +25,136 @@ def index_v1():
20 25
     }
21 26
 
22 27
 
23
-def flats_v1(db):
28
+def flats_v1(config, db):
24 29
     """
25 30
     API v1 flats route:
26 31
 
27 32
         GET /api/v1/flats
33
+
34
+    :return: The available flats objects in a JSON ``data`` dict.
28 35
     """
36
+    postal_codes = flatisfy.data.load_data("postal_codes", config)
37
+
29 38
     flats = [
30 39
         flat.json_api_repr()
31 40
         for flat in db.query(flat_model.Flat).all()
32 41
     ]
42
+
43
+    for flat in flats:
44
+        if flat["flatisfy_postal_code"]:
45
+            postal_code_data = postal_codes[flat["flatisfy_postal_code"]]
46
+            flat["flatisfy_postal_code"] = {
47
+                "postal_code": flat["flatisfy_postal_code"],
48
+                "name": postal_code_data["nom"],
49
+                "gps": postal_code_data["gps"]
50
+            }
51
+        else:
52
+            flat["flatisfy_postal_code"] = {}
53
+
54
+    return {
55
+        "data": flats
56
+    }
57
+
58
+
59
+def flats_by_status_v1(status, db):
60
+    """
61
+    API v1 flats route with a specific status:
62
+
63
+        GET /api/v1/flats/status/:status
64
+
65
+    :return: The matching flats objects in a JSON ``data`` dict.
66
+    """
67
+    try:
68
+        flats = [
69
+            flat.json_api_repr()
70
+            for flat in (
71
+                db.query(flat_model.Flat)
72
+                .filter_by(status=getattr(flat_model.FlatStatus, status))
73
+                .all()
74
+            )
75
+        ]
76
+    except AttributeError:
77
+        return bottle.HTTPError(400, "Invalid status provided.")
78
+
33 79
     return {
34 80
         "data": flats
35 81
     }
36 82
 
37 83
 
38
-def flat_v1(flat_id, db):
84
+def flat_v1(flat_id, config, db):
39 85
     """
40 86
     API v1 flat route:
41 87
 
42 88
         GET /api/v1/flat/:flat_id
89
+
90
+    :return: The flat object in a JSON ``data`` dict.
43 91
     """
92
+    postal_codes = flatisfy.data.load_data("postal_codes", config)
93
+
44 94
     flat = db.query(flat_model.Flat).filter_by(id=flat_id).first()
95
+
96
+    if not flat:
97
+        return bottle.HTTPError(404, "No flat with id {}.".format(flat_id))
98
+
99
+    flat = flat.json_api_repr()
100
+
101
+    if flat["flatisfy_postal_code"]:
102
+        postal_code_data = postal_codes[flat["flatisfy_postal_code"]]
103
+        flat["flatisfy_postal_code"] = {
104
+            "postal_code": flat["flatisfy_postal_code"],
105
+            "name": postal_code_data["nom"],
106
+            "gps": postal_code_data["gps"]
107
+        }
108
+    else:
109
+        flat["flatisfy_postal_code"] = {}
110
+
111
+    return {
112
+        "data": flat
113
+    }
114
+
115
+
116
+def update_flat_status_v1(flat_id, db):
117
+    """
118
+    API v1 route to update flat status:
119
+
120
+        POST /api/v1/flat/:flat_id/status
121
+        Data: {
122
+            "status": "NEW_STATUS"
123
+        }
124
+
125
+    :return: The new flat object in a JSON ``data`` dict.
126
+    """
127
+    flat = db.query(flat_model.Flat).filter_by(id=flat_id).first()
128
+    if not flat:
129
+        return bottle.HTTPError(404, "No flat with id {}.".format(flat_id))
130
+
131
+    try:
132
+        flat.status = getattr(
133
+            flat_model.FlatStatus, json.load(bottle.request.body)["status"]
134
+        )
135
+    except (AttributeError, ValueError, KeyError):
136
+        return bottle.HTTPError(400, "Invalid status provided.")
137
+
138
+    json_flat = flat.json_api_repr()
139
+
140
+    return {
141
+        "data": json_flat
142
+    }
143
+
144
+
145
+def time_to_places_v1(config):
146
+    """
147
+    API v1 route to fetch the details of the places to compute time to.
148
+
149
+        GET /api/v1/time_to/places
150
+
151
+    :return: The JSON dump of the places to compute time to (dict of places
152
+    names mapped to GPS coordinates).
153
+    """
154
+    places = {
155
+        k: v["gps"]
156
+        for k, v in config["constraints"]["time_to"].items()
157
+    }
45 158
     return {
46
-        "data": flat.json_api_repr()
159
+        "data": places
47 160
     }

+ 5
- 21
flatisfy/web/static/index.html View File

@@ -2,29 +2,13 @@
2 2
 <html lang="fr">
3 3
     <head>
4 4
         <meta charset="utf-8">
5
+        <meta name="format-detection" content="telephone=no">
5 6
         <title>Flatisfy</title>
6
-        <script src="https://unpkg.com/vue"></script>
7
+
8
+        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
7 9
     </head>
8 10
     <body>
9
-        <div id="app">
10
-            <h1>Flatisfy</h1>
11
-            <table>
12
-                <thead>
13
-                    <tr>
14
-                        <th>Titre</th>
15
-                        <th>Lien</th>
16
-                    </tr>
17
-                </thead>
18
-                <tbody>
19
-                </tbody>
20
-            </table>
21
-        </div>
22
-        <script type="text/javascript">
23
-            var app = new Vue({
24
-                el: '#app',
25
-                data: {
26
-                }
27
-            })
28
-        </script>
11
+        <div id="app"></div>
12
+        <script src="static/js/bundle.js"></script>
29 13
     </body>
30 14
 </html>

BIN
flatisfy/web/static/js/9e9c77db241e8a58da99bf28694c907d.png View File


BIN
flatisfy/web/static/js/a159160a02a1caf189f5008c0d150f36.png View File


BIN
flatisfy/web/static/js/c6477ef24ce2054017fce1a2d8800385.png View File


BIN
flatisfy/web/static/js/d3a5d64a8534322988a4bed1b7dbc8b0.png View File


+ 1
- 0
hooks/pre-commit View File

@@ -1,3 +1,4 @@
1 1
 #!/bin/sh
2 2
 
3 3
 pylint --rcfile=.ci/pylintrc flatisfy
4
+npm run lint

+ 51
- 0
package.json View File

@@ -0,0 +1,51 @@
1
+{
2
+  "name": "Flatisfy",
3
+  "description": "Flatisfy is your new companion to ease your search of a new housing :)",
4
+  "author": "Phyks (Lucas Verney) <phyks@phyks.me>",
5
+  "license": "MIT",
6
+  "version": "0.0.1",
7
+  "repository": {
8
+    "type": "git",
9
+    "url": "https://Phyks@git.phyks.me/Phyks/flatisfy.git"
10
+  },
11
+  "homepage": "https://git.phyks.me/Phyks/flatisfy",
12
+  "scripts": {
13
+    "build": "webpack --colors --progress",
14
+    "watch": "webpack --colors --progress --watch",
15
+    "lint": "eslint --ext .js,.vue ./flatisfy/web/js_src/**"
16
+  },
17
+  "dependencies": {
18
+    "es6-promise": "^4.1.0",
19
+    "imagesloaded": "^4.1.1",
20
+    "isomorphic-fetch": "^2.2.1",
21
+    "isotope-layout": "^3.0.3",
22
+    "leaflet.icon.glyph": "^0.2.0",
23
+    "masonry": "0.0.2",
24
+    "moment": "^2.18.1",
25
+    "vue": "^2.2.6",
26
+    "vue-i18n": "^6.1.1",
27
+    "vue-images-loaded": "^1.1.2",
28
+    "vue-router": "^2.4.0",
29
+    "vue2-leaflet": "0.0.44",
30
+    "vueisotope": "^3.0.0-rc",
31
+    "vuex": "^2.3.0"
32
+  },
33
+  "devDependencies": {
34
+    "babel-core": "^6.24.1",
35
+    "babel-loader": "^6.4.1",
36
+    "babel-plugin-transform-runtime": "^6.23.0",
37
+    "babel-preset-es2015": "^6.24.1",
38
+    "babel-preset-stage-0": "^6.24.1",
39
+    "css-loader": "^0.28.0",
40
+    "eslint": "^3.19.0",
41
+    "eslint-config-vue": "^2.0.2",
42
+    "eslint-plugin-vue": "^2.0.1",
43
+    "file-loader": "^0.11.1",
44
+    "image-webpack-loader": "^3.3.0",
45
+    "style-loader": "^0.16.1",
46
+    "vue-html-loader": "^1.2.4",
47
+    "vue-loader": "^11.3.4",
48
+    "vue-template-compiler": "^2.2.6",
49
+    "webpack": "^2.3.3"
50
+  }
51
+}

+ 2
- 0
requirements.txt View File

@@ -1,6 +1,8 @@
1 1
 appdirs
2
+arrow
2 3
 bottle
3 4
 bottle-sqlalchemy
5
+canister
4 6
 enum34
5