Browse Source

Add an ICS feed of visits

UI is minimalist and should be improved in the future. Fixes #40.
Phyks (Lucas Verney) 2 years ago
parent
commit
c936228726

+ 1
- 0
.gitignore View File

@@ -7,3 +7,4 @@ config/
7 7
 node_modules
8 8
 flatisfy/web/static/assets
9 9
 data/
10
+package-lock.json

+ 3
- 0
flatisfy/models/flat.py View File

@@ -93,6 +93,9 @@ class Flat(BASE):
93 93
     # Status
94 94
     status = Column(Enum(FlatStatus), default=FlatStatus.new)
95 95
 
96
+    # Date for visit
97
+    visit_date = Column(DateTime)
98
+
96 99
     @staticmethod
97 100
     def from_dict(flat_dict):
98 101
         """

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

@@ -83,6 +83,11 @@ def get_app(config):
83 83
               api_routes.update_flat_notes_v1)
84 84
     app.route("/api/v1/flat/:flat_id/notation", "POST",
85 85
               api_routes.update_flat_notation_v1)
86
+    app.route("/api/v1/flat/:flat_id/visit_date", "POST",
87
+              api_routes.update_flat_visit_date_v1)
88
+
89
+    app.route("/api/v1/visits.ics", "GET",
90
+              api_routes.ics_feed_v1)
86 91
 
87 92
     app.route("/api/v1/search", "POST", api_routes.search_v1)
88 93
 

+ 22
- 1
flatisfy/web/js_src/api/index.js View File

@@ -6,7 +6,10 @@ require('isomorphic-fetch')
6 6
 const postProcessAPIResults = function (flat) {
7 7
     /* eslint-disable camelcase */
8 8
     if (flat.date) {
9
-        flat.date = moment(flat.date)
9
+        flat.date = moment.utc(flat.date)
10
+    }
11
+    if (flat.visit_date) {
12
+        flat.visit_date = moment.utc(flat.visit_date)
10 13
     }
11 14
     if (flat.flatisfy_time_to) {
12 15
         const momentifiedTimeTo = {}
@@ -110,6 +113,24 @@ export const updateFlatNotation = function (flatId, newNotation, callback) {
110 113
     })
111 114
 }
112 115
 
116
+export const updateFlatVisitDate = function (flatId, newVisitDate, callback) {
117
+    fetch(
118
+        '/api/v1/flat/' + encodeURIComponent(flatId) + '/visit_date',
119
+        {
120
+            credentials: 'same-origin',
121
+            method: 'POST',
122
+            headers: {
123
+                'Content-Type': 'application/json'
124
+            },
125
+            body: JSON.stringify({
126
+                visit_date: newVisitDate
127
+            })
128
+        }
129
+    ).then(callback).catch(function (ex) {
130
+        console.error('Unable to update flat date of visit: ' + ex)
131
+    })
132
+}
133
+
113 134
 export const getTimeToPlaces = function (callback) {
114 135
     fetch('/api/v1/time_to_places', { credentials: 'same-origin' })
115 136
     .then(function (response) {

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

@@ -45,6 +45,8 @@ export default {
45 45
         'Times_to': 'Times to',
46 46
         'Location': 'Location',
47 47
         'Contact': 'Contact',
48
+        'Visit': 'Visit',
49
+        'setDateOfVisit': 'Set date of visit',
48 50
         'no_phone_found': 'No phone found',
49 51
         'rooms': 'room | rooms',
50 52
         'bedrooms': 'bedroom | bedrooms'

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

@@ -39,6 +39,12 @@ export default {
39 39
             commit(types.UPDATE_FLAT_NOTES, { flatId, newNotes })
40 40
         })
41 41
     },
42
+    updateFlatVisitDate ({ commit }, { flatId, newVisitDate }) {
43
+        commit(types.IS_LOADING)
44
+        api.updateFlatVisitDate(flatId, newVisitDate, response => {
45
+            commit(types.UPDATE_FLAT_VISIT_DATE, { flatId, newVisitDate })
46
+        })
47
+    },
42 48
     doSearch ({ commit }, { query }) {
43 49
         commit(types.IS_LOADING)
44 50
         api.doSearch(query, flats => {

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

@@ -3,5 +3,6 @@ export const MERGE_FLATS = 'MERGE_FLATS'
3 3
 export const UPDATE_FLAT_STATUS = 'UPDATE_FLAT_STATUS'
4 4
 export const UPDATE_FLAT_NOTES = 'UPDATE_FLAT_NOTES'
5 5
 export const UPDATE_FLAT_NOTATION = 'UPDATE_FLAT_NOTATION'
6
+export const UPDATE_FLAT_VISIT_DATE = 'UPDATE_FLAT_VISIT_DATE'
6 7
 export const RECEIVE_TIME_TO_PLACES = 'RECEIVE_TIME_TO_PLACES'
7 8
 export const IS_LOADING = 'IS_LOADING'

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

@@ -47,6 +47,13 @@ export const mutations = {
47 47
         }
48 48
         state.loading -= 1
49 49
     },
50
+    [types.UPDATE_FLAT_VISIT_DATE] (state, { flatId, newVisitDate }) {
51
+        const index = state.flats.findIndex(flat => flat.id === flatId)
52
+        if (index > -1) {
53
+            Vue.set(state.flats[index], 'visit-date', newVisitDate)
54
+        }
55
+        state.loading -= 1
56
+    },
50 57
     [types.RECEIVE_TIME_TO_PLACES] (state, { timeToPlaces }) {
51 58
         state.timeToPlaces = timeToPlaces
52 59
         state.loading -= 1

+ 39
- 2
flatisfy/web/js_src/views/details.vue View File

@@ -147,6 +147,15 @@
147 147
                     </p>
148 148
                 </div>
149 149
 
150
+                <h3>{{ $t("flatsDetails.Visit") }}</h3>
151
+                <div class="visit">
152
+                    <flat-pickr
153
+                        :value="flatpickrValue"
154
+                        :config="flatpickrConfig"
155
+                        :placeholder="$t('flatsDetails.setDateOfVisit')"
156
+                    />
157
+                </div>
158
+
150 159
                 <h3>{{ $t("common.Actions") }}</h3>
151 160
 
152 161
                 <nav>
@@ -187,7 +196,10 @@
187 196
 </template>
188 197
 
189 198
 <script>
199
+import flatPickr from 'vue-flatpickr-component'
200
+import moment from 'moment'
190 201
 import 'font-awesome-webpack'
202
+import 'flatpickr/dist/flatpickr.css'
191 203
 
192 204
 import FlatsMap from '../components/flatsmap.vue'
193 205
 import Slider from '../components/slider.vue'
@@ -197,7 +209,8 @@ import { capitalize, range } from '../tools'
197 209
 export default {
198 210
     components: {
199 211
         FlatsMap,
200
-        Slider
212
+        Slider,
213
+        flatPickr
201 214
     },
202 215
 
203 216
     created () {
@@ -220,7 +233,15 @@ export default {
220 233
 
221 234
     data () {
222 235
         return {
223
-            'overloadNotation': null
236
+            // TODO: Flatpickr locale
237
+            'overloadNotation': null,
238
+            'flatpickrConfig': {
239
+                static: true,
240
+                altFormat: 'h:i K, M j, Y',
241
+                altInput: true,
242
+                enableTime: true,
243
+                onChange: selectedDates => this.updateFlatVisitDate(selectedDates.length > 0 ? selectedDates[0] : null),
244
+            },
224 245
         }
225 246
     },
226 247
 
@@ -237,6 +258,12 @@ export default {
237 258
         flat () {
238 259
             return this.$store.getters.flat(this.$route.params.id)
239 260
         },
261
+        'flatpickrValue' () {
262
+            if (this.flat && this.flat.visit_date) {
263
+                return this.flat.visit_date.local().format()
264
+            }
265
+            return null
266
+        },
240 267
         timeToPlaces () {
241 268
             return this.$store.getters.timeToPlaces(this.flat.flatisfy_constraint)
242 269
         },
@@ -304,6 +331,16 @@ export default {
304 331
             )
305 332
         },
306 333
 
334
+        updateFlatVisitDate (date) {
335
+            if (date) {
336
+                date = moment(date).utc().format()
337
+            }
338
+            this.$store.dispatch(
339
+                'updateFlatVisitDate',
340
+                { flatId: this.$route.params.id, newVisitDate: date }
341
+            )
342
+        },
343
+
307 344
         humanizeTimeTo (time) {
308 345
             const minutes = Math.floor(time.as('minutes'))
309 346
             return minutes + ' ' + this.$tc('common.mins', minutes)

+ 69
- 0
flatisfy/web/routes/api.py View File

@@ -6,9 +6,12 @@ from __future__ import (
6 6
     absolute_import, division, print_function, unicode_literals
7 7
 )
8 8
 
9
+import datetime
9 10
 import json
10 11
 
12
+import arrow
11 13
 import bottle
14
+import vobject
12 15
 
13 16
 import flatisfy.data
14 17
 from flatisfy.models import flat as flat_model
@@ -222,6 +225,36 @@ def update_flat_notation_v1(flat_id, db):
222 225
     }
223 226
 
224 227
 
228
+def update_flat_visit_date_v1(flat_id, db):
229
+    """
230
+    API v1 route to update flat date of visit:
231
+
232
+        POST /api/v1/flat/:flat_id/visit_date
233
+        Data: {
234
+            "visit_date": "ISO8601 DATETIME"
235
+        }
236
+
237
+    :return: The new flat object in a JSON ``data`` dict.
238
+    """
239
+    flat = db.query(flat_model.Flat).filter_by(id=flat_id).first()
240
+    if not flat:
241
+        return bottle.HTTPError(404, "No flat with id {}.".format(flat_id))
242
+
243
+    try:
244
+        visit_date = json.load(bottle.request.body)["visit_date"]
245
+        if visit_date:
246
+            visit_date = arrow.get(visit_date).naive
247
+        flat.visit_date = visit_date
248
+    except (arrow.parser.ParserError, ValueError, KeyError):
249
+        return bottle.HTTPError(400, "Invalid visit date provided.")
250
+
251
+    json_flat = flat.json_api_repr()
252
+
253
+    return {
254
+        "data": json_flat
255
+    }
256
+
257
+
225 258
 def time_to_places_v1(config):
226 259
     """
227 260
     API v1 route to fetch the details of the places to compute time to.
@@ -290,3 +323,39 @@ def search_v1(db, config):
290 323
     return {
291 324
         "data": flats
292 325
     }
326
+
327
+
328
+def ics_feed_v1(config, db):
329
+    """
330
+    API v1 ICS feed of visits route:
331
+
332
+        GET /api/v1/visits.ics
333
+
334
+    :return: The ICS feed for the visits.
335
+    """
336
+    flats_with_visits = db.query(flat_model.Flat).filter(
337
+        flat_model.Flat.visit_date.isnot(None)
338
+    ).all()
339
+
340
+    cal = vobject.iCalendar()
341
+    for flat in flats_with_visits:
342
+        vevent = cal.add('vevent')
343
+        vevent.add('dtstart').value = flat.visit_date
344
+        vevent.add('dtend').value = (
345
+            flat.visit_date + datetime.timedelta(hours=1)
346
+        )
347
+        vevent.add('summary').value = 'Visit - {}'.format(flat.title)
348
+
349
+        description = (
350
+            '{} (area: {}, cost: {} {})\n{}#/flat/{}\n'.format(
351
+                flat.title, flat.area, flat.cost, flat.currency,
352
+                config['website_url'], flat.id
353
+            )
354
+        )
355
+        description += '\n{}\n'.format(flat.text)
356
+        if flat.notes:
357
+            description += '\n{}\n'.format(flat.notes)
358
+
359
+        vevent.add('description').value = description
360
+
361
+    return cal.serialize()

+ 1
- 0
package.json View File

@@ -27,6 +27,7 @@
27 27
     "masonry": "0.0.2",
28 28
     "moment": "^2.18.1",
29 29
     "vue": "^2.2.6",
30
+    "vue-flatpickr-component": "^4.0.0",
30 31
     "vue-i18n": "^6.1.1",
31 32
     "vue-images-loaded": "^1.1.2",
32 33
     "vue-router": "^2.4.0",

+ 1
- 0
requirements.txt View File

@@ -10,6 +10,7 @@ pillow
10 10
 requests
11 11
 sqlalchemy
12 12
 unidecode
13
+vobject
13 14
 whoosh
14 15
 https://git.weboob.org/weboob/devel/repository/archive.zip?ref=master
15 16
 https://git.weboob.org/weboob/modules/repository/archive.zip?ref=master