Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
## Список задач

1. Починить тесты
* `testBudgetPagination` - некорректно работает пагинация + неправильно считается общая статистика записей
* `testStatsSortOrder` - необходимо реализовать сортировку выдачи в указанном порядке
* `testBudgetPagination` - некорректно работает пагинация + неправильно считается общая статистика записей
* `testStatsSortOrder` - необходимо реализовать сортировку выдачи в указанном порядке
2. Из модели `BudgetType` через миграцию БД убрать значение `Комиссия`, заменив его на `Расход`
3. Добавить таблицу `Author` - автор внесения записи.
* 3 колонки - `ID`, `ФИО`, `Дата создания` (дата-время).
* 3 колонки - `ID`, `ФИО`, `Дата создания` (дата-время).
* Добавить в апи метод создания новой записи в `Author`. На вход передается ФИО, дата создания проставляется сервером автоматически.
* В `BudgetTable` добавить опциональную привязку по `Author.id`
* Дополнить `/budget/add` возможностью указать ID автора (опциональное поле)
* В элементах ответа `/budget/year/{year}/stats` выводить ФИО автора, если он указан для записи, а также время создания записи автора.
* Добавить в параметры запроса `/budget/year/{year}/stats` опциональный фильтр по ФИО автора и фильтровать по совпадению подстроки игнорируя регистр

## Последние изменения
1. Исправлены все тесты
2. Тип `BudgetType` - `Комиссия` и так ни где не использовался
3. Добавлена таблица `Author`
* 3 колонки - `ID`, `ФИО`, `Дата создания` (дата-время).
* Добавлен в API метод создания новой записи в `Author`.
На вход передается ФИО, дата создания проставляется сервером автоматически.
На выход передается ФИО и дата создания сущности
* В `BudgetTable` добавлена опциональная привязка по `Author.id`:
* теперь сущность `BudgetRecord` может передаваться на вход как с ID автора так и без него
* если `BudgetRecord` передается с ID, то в ответ приходит `BudgetResponce`, с полем AuthorName
* если `BudgetRecord` не передается с ID, то в ответ приходит `BudgetResponce`, без поля AuthorName
4. В элементах ответа /budget/year/{year}/stats выводить ФИО автора,
если он указан для записи, а также время создания записи автора.
5. Добавлен в параметр запроса `/budget/year/{year}/stats`
опциональный фильтр по ФИО автора и возможность фильтровать по совпадению подстроки игнорируя регистр.

Реализация пунктов 4, 5 произведена по средствам использования абстрактного класса.

Кроме того, были добавлены:
* `BudgetResponseDeserializer` JSON для корректной работы тестов
* Тесты нового функционала
32 changes: 22 additions & 10 deletions src/main/java/mobi/sevenwinds/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@ import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
import com.papsign.ktor.openapigen.annotations.type.common.ConstraintViolation
import com.papsign.ktor.openapigen.exceptions.OpenAPIRequiredFieldException
import com.papsign.ktor.openapigen.route.apiRouting
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.jackson.*
import io.ktor.locations.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.netty.*
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.CORS
import io.ktor.features.CallLogging
import io.ktor.features.ContentNegotiation
import io.ktor.features.DefaultHeaders
import io.ktor.features.NotFoundException
import io.ktor.features.StatusPages
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson
import io.ktor.locations.Locations
import io.ktor.request.path
import io.ktor.response.respond
import io.ktor.routing.routing
import io.ktor.server.netty.EngineMain
import mobi.sevenwinds.app.Config
import mobi.sevenwinds.app.author.author
import mobi.sevenwinds.modules.DatabaseFactory
import mobi.sevenwinds.modules.initSwagger
import mobi.sevenwinds.modules.serviceRouting
Expand Down Expand Up @@ -49,7 +59,7 @@ fun Application.module() {
level = Level.INFO
filter { call ->
Config.logAllRequests ||
call.request.path().startsWith("/")
call.request.path().startsWith("/")
&& (call.response.status()?.value ?: 0) >= 500
}
}
Expand All @@ -59,6 +69,7 @@ fun Application.module() {
method(HttpMethod.Put)
method(HttpMethod.Delete)
method(HttpMethod.Patch)
method(HttpMethod.Post)
header(HttpHeaders.Authorization)
header(HttpHeaders.ContentType)
header(HttpHeaders.AccessControlAllowOrigin)
Expand All @@ -69,6 +80,7 @@ fun Application.module() {
}

apiRouting {
author()
swaggerRouting()
}

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/mobi/sevenwinds/Const.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ object Const {
const val jwtAuth = "auth-jwt"

const val version = "0.10.8"

const val dateTimeFormatPattern = "yyyy-MM-dd HH:mm:ss"
}
2 changes: 1 addition & 1 deletion src/main/java/mobi/sevenwinds/app/Config.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package mobi.sevenwinds.app

import io.ktor.config.*
import io.ktor.config.ApplicationConfig

object Config {
val logAllRequests by lazy { config.propertyOrNull("ktor.logAllRequests")?.getString()?.toBoolean() == true }
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/mobi/sevenwinds/app/author/AuthorApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package mobi.sevenwinds.app.author

import com.papsign.ktor.openapigen.annotations.type.string.length.Length
import com.papsign.ktor.openapigen.route.info
import com.papsign.ktor.openapigen.route.path.normal.NormalOpenAPIRoute
import com.papsign.ktor.openapigen.route.path.normal.post
import com.papsign.ktor.openapigen.route.response.respond
import com.papsign.ktor.openapigen.route.route
import org.jetbrains.annotations.NotNull

fun NormalOpenAPIRoute.author() {
route("/author") {
route("/add").post<Unit, AuthorResponse, AuthorRecord>(info("Добавить автора")) { param, body ->
respond(AuthorService.addRecord(body))
}
}
}

data class AuthorRecord(
@NotNull @Length(min = 2, max = 255) var fullName: String
)

data class AuthorResponse(
val fullName: String,
val createdAt: String
)
18 changes: 18 additions & 0 deletions src/main/java/mobi/sevenwinds/app/author/AuthorService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package mobi.sevenwinds.app.author

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.sql.transactions.transaction
import org.joda.time.DateTime

object AuthorService {
suspend fun addRecord(body: AuthorRecord): AuthorResponse = withContext(Dispatchers.IO) {
transaction {
val entity = AuthorEntity.new {
this.fullName = body.fullName
this.createdAt = DateTime.now()
}
return@transaction entity.toResponse()
}
}
}
27 changes: 27 additions & 0 deletions src/main/java/mobi/sevenwinds/app/author/AuthorTable.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package mobi.sevenwinds.app.author

import mobi.sevenwinds.Const.dateTimeFormatPattern
import org.jetbrains.exposed.dao.EntityID
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.IntIdTable
import org.joda.time.format.DateTimeFormat


object AuthorTable : IntIdTable("author") {
val fullName = varchar("full_name", 255)
val createdAt = datetime("created_at")
}

class AuthorEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<AuthorEntity>(AuthorTable)

var fullName by AuthorTable.fullName
var createdAt by AuthorTable.createdAt

fun toResponse(): AuthorResponse {
return AuthorResponse(
fullName, createdAt.toString(DateTimeFormat.forPattern(dateTimeFormatPattern))
)
}
}
62 changes: 59 additions & 3 deletions src/main/java/mobi/sevenwinds/app/budget/BudgetApi.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package mobi.sevenwinds.app.budget

import BudgetResponseDeserializer
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.papsign.ktor.openapigen.annotations.parameters.PathParam
import com.papsign.ktor.openapigen.annotations.parameters.QueryParam
import com.papsign.ktor.openapigen.annotations.type.number.integer.max.Max
Expand All @@ -13,7 +15,7 @@ import com.papsign.ktor.openapigen.route.route

fun NormalOpenAPIRoute.budget() {
route("/budget") {
route("/add").post<Unit, BudgetRecord, BudgetRecord>(info("Добавить запись")) { param, body ->
route("/add").post<Unit, BudgetResponse, BudgetRecord>(info("Добавить запись")) { param, body ->
respond(BudgetService.addRecord(body))
}

Expand All @@ -29,19 +31,73 @@ data class BudgetRecord(
@Min(1900) val year: Int,
@Min(1) @Max(12) val month: Int,
@Min(1) val amount: Int,
val type: BudgetType
val type: BudgetType,
val authorId: Int?
)

@JsonDeserialize(using = BudgetResponseDeserializer::class)
abstract class BudgetResponse(
open val year: Int,
open val month: Int,
open val amount: Int,
open val type: BudgetType
) {
abstract fun displayInfo(): String
}

class BudgetResponseWithoutAuthor(
year: Int,
month: Int,
amount: Int,
type: BudgetType
) : BudgetResponse(year, month, amount, type) {

override fun displayInfo(): String { // Реализуем метод
return "year: $year, month: $month, amount: $amount, type: $type"
}
}

class BudgetResponseWithAuthor(
year: Int,
month: Int,
amount: Int,
type: BudgetType,
val authorName: String
) : BudgetResponse(year, month, amount, type) {

override fun displayInfo(): String {
return "year: $year, month: $month, amount: $amount, type: $type, " +
"authorName: $authorName"
}
}

class BudgetResponseWithAuthorAndAuthorCreateAt(
year: Int,
month: Int,
amount: Int,
type: BudgetType,
val authorName: String,
val createAtAuthor: String
) : BudgetResponse(year, month, amount, type) {

override fun displayInfo(): String {
return "year: $year, month: $month, amount: $amount, type: $type, " +
"authorName: $authorName, authorCreateAt: $createAtAuthor"
}
}


data class BudgetYearParam(
@PathParam("Год") val year: Int,
@QueryParam("Лимит пагинации") val limit: Int,
@QueryParam("Смещение пагинации") val offset: Int,
@QueryParam("ФИО автора (опционально)") val authorName: String?,
)

class BudgetYearStatsResponse(
val total: Int,
val totalByType: Map<String, Int>,
val items: List<BudgetRecord>
val items: List<BudgetResponse>
)

enum class BudgetType {
Expand Down
31 changes: 26 additions & 5 deletions src/main/java/mobi/sevenwinds/app/budget/BudgetService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ package mobi.sevenwinds.app.budget

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mobi.sevenwinds.app.author.AuthorTable
import org.jetbrains.exposed.dao.EntityID
import org.jetbrains.exposed.sql.JoinType
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.lowerCase
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction

object BudgetService {
suspend fun addRecord(body: BudgetRecord): BudgetRecord = withContext(Dispatchers.IO) {
suspend fun addRecord(body: BudgetRecord): BudgetResponse = withContext(Dispatchers.IO) {
transaction {
val entity = BudgetEntity.new {
this.year = body.year
this.month = body.month
this.amount = body.amount
this.type = body.type
this.authorId = body.authorId?.let { EntityID(it, AuthorTable) }
}

return@transaction entity.toResponse()
Expand All @@ -21,12 +27,27 @@ object BudgetService {

suspend fun getYearStats(param: BudgetYearParam): BudgetYearStatsResponse = withContext(Dispatchers.IO) {
transaction {
val query = BudgetTable
.select { BudgetTable.year eq param.year }
.limit(param.limit, param.offset)
val query = if (param.authorName != null) {
BudgetTable
.join(AuthorTable, JoinType.INNER)
.select {
BudgetTable.year eq param.year and
(AuthorTable.fullName.lowerCase()
like "%${param.authorName}%".toLowerCase())
// Фильтрация по ФИО я бы использовал iLike, но видимо зависимости в проекте устаревшие
}
.limit(param.limit, param.offset)
} else {
BudgetTable
.select { BudgetTable.year eq param.year }
.limit(param.limit, param.offset)
}

val total = query.count()
val data = BudgetEntity.wrapRows(query).map { it.toResponse() }

// добавил сортировку согласно ТЗ
val data = BudgetEntity.wrapRows(query).map { it.toResponse(includeAuthorCreatedAt = true) }
.sortedWith(compareBy({ it.month }, { -it.amount }))

val sumByType = data.groupBy { it.type.name }.mapValues { it.value.sumOf { v -> v.amount } }

Expand Down
Loading